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

Page Title

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

` and subsections in `

`, `

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

{data.title}

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

+ All Creators +

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

+ About Access Layer +

+

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

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

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

+ About Access Layer +

+

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

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

Loading…

; + if (error) return

Failed to load holdings.

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

Loading…

; + if (error) return

Could not load holdings.

; + if (!holdings?.length) return

No holdings yet.

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

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

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

Couldn't load the creator list

+

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

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

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

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

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

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

+ The idea +

+``` + +Body copy sits in `

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

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

+ Failed to load profile details +

+

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

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

{creator?.name}

+

{creator?.bio}

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

Sibling clicked

: null} +
+ ); +}; + +const renderWithRouter = (ui: ReactElement) => + render({ui}); + +describe('CreatorPageErrorBoundary integration', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('catches render faults while keeping the rest of the app mounted', async () => { + const user = userEvent.setup(); + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + renderWithRouter( +
+ + + + +
+ ); + + expect(screen.getByRole('alert')).toBeInTheDocument(); + expect(screen.getByText(/this creator page could not load/i)).toBeInTheDocument(); + expect(screen.getByRole('link', { name: /back to creators/i })).toHaveAttribute( + 'href', + '/creators' + ); + expect(screen.getByRole('button', { name: /healthy sibling/i })).toBeInTheDocument(); + + await user.click(screen.getByRole('button', { name: /healthy sibling/i })); + + expect(screen.getByText('Sibling clicked')).toBeInTheDocument(); + expect(consoleErrorSpy).toHaveBeenCalled(); + }); +}); diff --git a/src/components/__tests__/FollowButton.test.tsx b/src/components/__tests__/FollowButton.test.tsx new file mode 100644 index 00000000..002529ee --- /dev/null +++ b/src/components/__tests__/FollowButton.test.tsx @@ -0,0 +1 @@ +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; import { describe, it, expect, vi } from 'vitest'; import FollowButton from '../common/FollowButton'; describe('FollowButton', () => { it('renders Follow when not following', () => { render(); expect(screen.getByTestId('follow-button')).toHaveTextContent('Follow'); }); it('renders Following when following', () => { render(); expect(screen.getByTestId('follow-button')).toHaveTextContent('Following'); }); it('calls onFollow with correct address when not following', async () => { const onFollow = vi.fn().mockResolvedValue(undefined); render(); fireEvent.click(screen.getByTestId('follow-button')); await waitFor(() => expect(onFollow).toHaveBeenCalledWith('0x123')); }); it('calls onUnfollow when already following', async () => { const onUnfollow = vi.fn().mockResolvedValue(undefined); render(); fireEvent.click(screen.getByTestId('follow-button')); await waitFor(() => expect(onUnfollow).toHaveBeenCalled()); }); it('disables button and shows loading while pending', async () => { const onFollow = vi.fn().mockImplementation(() => new Promise(resolve => setTimeout(resolve, 100))); render(); fireEvent.click(screen.getByTestId('follow-button')); expect(screen.getByTestId('follow-button')).toBeDisabled(); expect(screen.getByTestId('follow-button')).toHaveTextContent('Loading...'); await waitFor(() => expect(onFollow).toHaveBeenCalled()); }); }); \ No newline at end of file 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/Change24hBadge.tsx b/src/components/common/Change24hBadge.tsx index ad995796..99f70c6c 100644 --- a/src/components/common/Change24hBadge.tsx +++ b/src/components/common/Change24hBadge.tsx @@ -9,8 +9,10 @@ interface Change24hBadgeProps { } const Change24hBadge: React.FC = ({ change, className }) => { - const isPositive = change !== undefined && change > 0; - const isNegative = change !== undefined && change < 0; + if (change == null) return null; + + const isPositive = change > 0; + const isNegative = change < 0; const formatted = formatPercent(change, { signed: true }); return ( diff --git a/src/components/common/ConnectWalletButton.tsx b/src/components/common/ConnectWalletButton.tsx index c72d8520..d2ca0811 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, @@ -8,20 +9,33 @@ import { DialogFooter, DialogHeader, DialogTitle, - DialogTrigger, } from '@/components/ui/dialog'; import { Button } from '@/components/ui/button'; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from '@/components/ui/popover'; import { shortenAddress } from '@/lib/web3/format'; 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 [showAddressPopover, setShowAddressPopover] = useState(false); + const [copied, setCopied] = useState(false); + const connectedAtRef = useRef(null); const [showDisconnectDialog, setShowDisconnectDialog] = useState(false); const { address, isConnected } = useAccount(); const { connect, connectors, error, isPending } = useConnect(); const { disconnect } = useDisconnect(); + const { announcement, announceCopySuccess } = useCopySuccessAnnouncement(); const primaryConnector = connectors[0]; const showAdBlockerSuggestion = useWalletConnectionStallDetection({ @@ -29,45 +43,133 @@ 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. - - - - - - - - - - + + + + +
+
+ + Connected Wallet + + +
+
+

+ {address} +

+
+
+ + +
+
+
+ +
+ + + + Disconnect wallet? + + Disconnecting clears your current wallet session and any + pending wallet state. You will need to reconnect to + continue. + + + + + + + + + + + + ); } 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/CreatorActivityFeed.tsx b/src/components/common/CreatorActivityFeed.tsx new file mode 100644 index 00000000..600f34b4 --- /dev/null +++ b/src/components/common/CreatorActivityFeed.tsx @@ -0,0 +1,107 @@ +import { ArrowDownRight, ArrowUpRight, History } from 'lucide-react'; +import { useCreatorActivityFeed } from '@/hooks/useCreatorActivityFeed'; +import { creatorActivityService } from '@/services/creatorActivity.service'; +import { formatRelativeTime } from '@/utils/time.utils'; +import { formatCreatorHandle } from '@/utils/handleDisplay.utils'; + +export interface CreatorActivityFeedProps { + creatorId: string; +} + +const SKELETON_ROW_COUNT = 3; + +function ActivityFeedSkeletonRows() { + return ( +
+ {Array.from({ length: SKELETON_ROW_COUNT }).map((_, index) => ( +
+
+
+
+
+
+
+ ))} +
+ ); +} + +/** + * Creator profile activity feed (#698): shows the creator's recent public + * trade activity, with a proper empty state when there is none. + * + * State machine: + * loading -> skeleton rows (never the empty state, even if `trades` + * happens to still be `[]` from a previous query). + * settled, trades.length === 0 -> empty state with the exact copy from + * the issue spec. + * settled, trades.length > 0 -> real rows. + */ +const CreatorActivityFeed: React.FC = ({ creatorId }) => { + const { trades, isLoading } = useCreatorActivityFeed(creatorId, id => + creatorActivityService.getCreatorActivity(id) + ); + + if (isLoading) { + return ; + } + + if (trades.length === 0) { + return ( +
+
+
+

+ No activity yet — buy or sell keys to get started +

+
+ ); + } + + return ( +
+ {trades.map(trade => ( +
+
+ {trade.type === 'buy' ? ( + + ) : ( + + )} +
+
+
+ + {trade.type === 'buy' ? 'Buy' : 'Sell'} + + + + {formatCreatorHandle(trade.traderHandle)} + +
+
+ {trade.amount} keys + + {trade.price} XLM + + {formatRelativeTime(trade.timestamp)} +
+
+
+ ))} +
+ ); +}; + +export default CreatorActivityFeed; 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 +110,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, @@ -137,43 +151,59 @@ const CreatorCard: React.FC = ({ creatorId: creator.id, creatorTitle: displayCreatorName, }); + + // Simulated transaction hash — replace with the real hash once the + // on-chain mutation is wired up. + const mockTxHash = + '0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef'; + const explorerUrl = buildStellarExpertTxUrl( + mockTxHash, + env.VITE_STELLAR_NETWORK + ); + showToast.transactionSuccess( - 'Purchase Successful!', - `You successfully bought a key for ${displayCreatorName}`, - '0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef', - 'https://stellar.expert/explorer/testnet/tx/0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef' + 'Transaction confirmed', + truncateTxHash(mockTxHash), + mockTxHash, + explorerUrl ); window.setTimeout(() => { 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 +223,14 @@ const CreatorCard: React.FC = ({ }); // Implementation for contract interaction would go here runPurchaseAttempt(); - }; + }, [isConnected, isNetworkMismatch, expectedChainName, displayCreatorName, runPurchaseAttempt]); return (

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

@@ -324,7 +356,7 @@ const CreatorCard: React.FC = ({ creatorShareSupply={creator.creatorShareSupply} isVerified={creator.isVerified} > - {displaySocialHandle} + {truncatedSocialHandle}
) : ( @@ -339,32 +371,47 @@ const CreatorCard: React.FC = ({
)} - {/* Sparkline placeholder */} -
-
- - - - - - - - - - {priceChartAccessibility.points.map(point => ( - - - - - ))} - -
{priceChartAccessibility.summary}
PointKey price
{point.label}{point.value}
-
+ {/* 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.points.map(point => ( + + + + + ))} + +
{priceChartAccessibility.summary}
PointKey price
{point.label}{point.value}
+
+
+ ); + })()}
@@ -410,7 +457,7 @@ const CreatorCard: React.FC = ({ } value={ creator.socialHandle - ? displaySocialHandle + ? truncatedSocialHandle : 'No public handle' } valueTitle={ @@ -476,7 +523,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..27774a74 --- /dev/null +++ b/src/components/common/CreatorMarketplaceInfiniteList.tsx @@ -0,0 +1,129 @@ +import { useEffect, useMemo, useState } from 'react'; +import { useInfiniteCreatorMarketplace } from '@/hooks/useInfiniteCreatorMarketplace'; +import { useInfiniteScroll } from '@/hooks/useInfiniteScroll'; +import CreatorCard from '@/components/common/CreatorCard'; +import SearchBar from '@/components/common/SearchBar'; +import { CreatorGridSkeleton } from '@/components/common/CreatorSkeleton'; +import type { GetCoursesParams } from '@/services/course.service'; + +export interface CreatorMarketplaceInfiniteListProps { + params?: Omit; +} + +const SEARCH_DEBOUNCE_MS = 300; + +/** + * Creator key marketplace listing with IntersectionObserver-driven infinite + * scroll (#685): fetches the first page on mount, then automatically fetches + * subsequent pages via useInfiniteQuery as the user scrolls the sentinel + * element into view. Shows a skeleton row while the next page is loading and + * stops fetching once the backend reports no more pages. + * + * A search bar (#699) filters the currently-loaded creators client-side by + * display name (case-insensitive substring match), debounced by 300ms. This + * filters within the page(s) already fetched rather than issuing a new + * server request per keystroke — the cursor-based pagination in + * useInfiniteCreatorMarketplace has no server-side name filter today. + */ +export default function CreatorMarketplaceInfiniteList({ + params, +}: CreatorMarketplaceInfiniteListProps) { + const { + creators, + hasMore, + isLoadingFirstPage, + isFetchingNextPage, + isRefreshing, + fetchNextPage, + } = useInfiniteCreatorMarketplace(params); + + const [searchQuery, setSearchQuery] = useState(''); + const [debouncedQuery, setDebouncedQuery] = useState(''); + + useEffect(() => { + const timer = setTimeout(() => setDebouncedQuery(searchQuery), SEARCH_DEBOUNCE_MS); + return () => clearTimeout(timer); + }, [searchQuery]); + + // Reset the filter whenever the loaded creator set changes identity due + // to a new cursor/page landing, per the issue's "filter resets when the + // page changes" acceptance criterion — a stale query shouldn't silently + // keep hiding newly-loaded creators from a previous page's context. Both + // the live input value and the debounced value used for filtering are + // cleared together so there's no window where they disagree. + const creatorsKey = creators.map(creator => creator.id).join(','); + const [lastCreatorsKey, setLastCreatorsKey] = useState(creatorsKey); + if (creatorsKey !== lastCreatorsKey) { + setLastCreatorsKey(creatorsKey); + if (searchQuery) setSearchQuery(''); + if (debouncedQuery) setDebouncedQuery(''); + } + + const trimmedQuery = debouncedQuery.trim().toLowerCase(); + const filteredCreators = useMemo(() => { + if (!trimmedQuery) return creators; + return creators.filter(creator => creator.title.toLowerCase().includes(trimmedQuery)); + }, [creators, trimmedQuery]); + + const sentinelRef = useInfiniteScroll({ + enabled: !isLoadingFirstPage && !isFetchingNextPage, + hasMore: Boolean(hasMore), + onLoadMore: () => { + void fetchNextPage(); + }, + }); + + if (isLoadingFirstPage) { + return ( +
+ +
+ ); + } + + return ( +
+ {isRefreshing && ( +
+ Refreshing… +
+ )} + + + {trimmedQuery && filteredCreators.length === 0 ? ( +

+ No results for "{debouncedQuery.trim()}" +

+ ) : ( +
+ {filteredCreators.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..c3acff48 100644 --- a/src/components/common/CreatorProfileHeader.tsx +++ b/src/components/common/CreatorProfileHeader.tsx @@ -1,7 +1,6 @@ -import React, { useState, useEffect } from 'react'; +import React, { useState, useEffect, useRef } from 'react'; import { motion } from 'framer-motion'; -import { Copy, Check, Share2 } from 'lucide-react'; -import showToast from '@/utils/toast.util'; +import { Share2, Pencil } from 'lucide-react'; import appendUtmParams from '@/utils/utm.utils'; import { Button } from '@/components/ui/button'; import { cn } from '@/lib/utils'; @@ -11,6 +10,8 @@ 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'; +import { useFormatXlm } from '@/hooks/useFormatXlm'; interface CreatorProfileHeaderProps { name: string; @@ -19,12 +20,16 @@ interface CreatorProfileHeaderProps { avatarUrl?: string; isVerified?: boolean; bio?: string | null; + priceStroops?: number | null; className?: string; + connectedWalletAddress?: string | null; } const CREATOR_PROFILE_SUBTITLE_WRAP_CLASS_NAME = 'max-w-full whitespace-normal break-words [overflow-wrap:anywhere]'; +const COPIED_FEEDBACK_MS = 2000; + const CreatorProfileHeader: React.FC = ({ name, handle, @@ -32,10 +37,14 @@ const CreatorProfileHeader: React.FC = ({ avatarUrl, isVerified, bio, + priceStroops, className, + connectedWalletAddress, }) => { const [copied, setCopied] = useState(false); const [isScrolled, setIsScrolled] = useState(false); + const copiedTimeoutRef = useRef | null>(null); + const { format } = useFormatXlm(); useEffect(() => { const handleScroll = () => { @@ -45,44 +54,56 @@ const CreatorProfileHeader: React.FC = ({ return () => window.removeEventListener('scroll', handleScroll); }, []); + useEffect(() => { + return () => { + if (copiedTimeoutRef.current) { + clearTimeout(copiedTimeoutRef.current); + } + }; + }, []); + // Display-normalised handle; raw `handle` is preserved for any equality / // 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 handleShare = async () => { - let url = window.location.href; + const own = isOwnWallet(connectedWalletAddress, normalizedCreatorId); - // Append UTM params when configured (no-op if none configured) - url = appendUtmParams(url); + const handleShare = async () => { + const url = appendUtmParams(window.location.href); + const canUseClipboard = + typeof navigator !== 'undefined' && + typeof navigator.clipboard?.writeText === 'function'; - if (navigator.share) { + if (canUseClipboard) { try { - await navigator.share({ - title: `${displayName} (${displayHandle || `@${handle}`}) on Access Layer`, - url, - }); - } catch (err) { - // User cancelled the share dialog — not an error worth surfacing - if (err instanceof Error && err.name !== 'AbortError') { - showToast.error('Failed to share profile'); + await navigator.clipboard.writeText(url); + setCopied(true); + if (copiedTimeoutRef.current) { + clearTimeout(copiedTimeoutRef.current); } + copiedTimeoutRef.current = setTimeout(() => { + setCopied(false); + copiedTimeoutRef.current = null; + }, COPIED_FEEDBACK_MS); + return; + } catch { + // Fall through to the prompt fallback below. } - return; } - // Fallback: copy to clipboard - try { - await navigator.clipboard.writeText(url); - setCopied(true); - showToast.success('Profile link copied to clipboard!'); - setTimeout(() => setCopied(false), 2000); - } catch { - showToast.error('Failed to copy link'); - } + window.prompt(url); }; - const canNativeShare = typeof navigator !== 'undefined' && !!navigator.share; + const displayPrice = + priceStroops != null && Number.isFinite(priceStroops) + ? `${format(priceStroops)} XLM` + : null; + + // Issue #724: omit the share control during SSR (`window` undefined). + const canShowShareButton = typeof window !== 'undefined'; return (
= ({ collapsible className="mt-2 max-w-md" /> + {displayPrice && ( +
+ + Current key price + + + {displayPrice} + +
+ )}
) : (

@@ -166,32 +197,55 @@ const CreatorProfileHeader: React.FC = ({ isScrolled ? 'scale-90' : 'scale-100' )} > - + {own && ( + <> + + + + )} + {canShowShareButton && ( + + )}

diff --git a/src/components/common/CreatorSkeleton.tsx b/src/components/common/CreatorSkeleton.tsx index 67d00a1d..05ce1351 100644 --- a/src/components/common/CreatorSkeleton.tsx +++ b/src/components/common/CreatorSkeleton.tsx @@ -129,13 +129,15 @@ export const CreatorProfileHeaderSkeleton: React.FC<{ export const CreatorHoldingsSkeleton: React.FC<{ className?: string; disableShimmer?: boolean; -}> = ({ className, disableShimmer = false }) => { + 'data-testid'?: string; +}> = ({ className, disableShimmer = false, ...rest }) => { const blockClass = disableShimmer ? skeletonStaticBlockClass : skeletonBlockClass; return (
= ({ count = 3, disableShimmer = false, className }) => { return ( -
+
+ Loading holdings {Array.from({ length: count }).map((_, i) => ( - + ))}
); diff --git a/src/components/common/EmptyState.tsx b/src/components/common/EmptyState.tsx index c1349413..e448a3e6 100644 --- a/src/components/common/EmptyState.tsx +++ b/src/components/common/EmptyState.tsx @@ -1,12 +1,23 @@ +import { Link } from 'react-router'; import { cn } from '@/lib/utils'; import { Button } from '@/components/ui/button'; import { RotateCcw } from 'lucide-react'; import { EMPTY_STATE_ILLUSTRATION_SIZES } from './emptyStateIllustration.config'; -interface EmptyStateProps { - image: string; - title: string; - description: string; +export interface EmptyStateCTAConfig { + label?: string; + text?: string; + href?: string; + onClick?: () => void; +} + +export interface EmptyStateProps { + image?: string; + title?: string; + description?: string; + message?: string; + cta?: EmptyStateCTAConfig | string; + ctaHref?: string; className?: string; onReset?: () => void; } @@ -15,9 +26,24 @@ const EmptyState: React.FC = ({ image, title, description, + message, + cta, + ctaHref, className, onReset, }: EmptyStateProps) => { + const displayTitle = title; + const displayMessage = message ?? description; + const ariaLabelText = title || message || description || 'Empty state'; + + const ctaObject = typeof cta === 'object' ? cta : undefined; + const ctaLabel = + ctaObject?.label ?? + ctaObject?.text ?? + (typeof cta === 'string' ? cta : undefined); + const resolvedHref = ctaObject?.href ?? ctaHref; + const resolvedOnClick = ctaObject?.onClick; + return (
= ({ className )} role="status" - aria-label={title} + aria-label={ariaLabelText} > -
-
- -
-

- {title} -

-

- {description} -

+ > +
+ +
+ )} + {displayTitle && ( +

+ {displayTitle} +

+ )} + {displayMessage !== undefined && ( +

+ {displayMessage} +

+ )} - {onReset && ( + {resolvedHref && ctaLabel ? ( + + {ctaLabel} + + ) : ctaLabel ? ( + + ) : 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/FollowButton.tsx b/src/components/common/FollowButton.tsx new file mode 100644 index 00000000..8d88d30d --- /dev/null +++ b/src/components/common/FollowButton.tsx @@ -0,0 +1,42 @@ +import { useState } from 'react'; +import { Button } from '@/components/ui/button'; + +interface FollowButtonProps { + creatorAddress: string; + isFollowing: boolean; + onFollow: (creatorAddress: string) => Promise; + onUnfollow: (creatorAddress: string) => Promise; +} + +export default function FollowButton({ + creatorAddress, + isFollowing, + onFollow, + onUnfollow, +}: FollowButtonProps) { + const [loading, setLoading] = useState(false); + + const handleClick = async () => { + setLoading(true); + try { + if (isFollowing) { + await onUnfollow(creatorAddress); + } else { + await onFollow(creatorAddress); + } + } finally { + setLoading(false); + } + }; + + return ( + + ); +} \ No newline at end of file diff --git a/src/components/common/HoldingsEmptyState.tsx b/src/components/common/HoldingsEmptyState.tsx new file mode 100644 index 00000000..e0254e31 --- /dev/null +++ b/src/components/common/HoldingsEmptyState.tsx @@ -0,0 +1,63 @@ +import { ArrowRight, KeyRound } from 'lucide-react'; +import { Link } from 'react-router'; +import { cn } from '@/lib/utils'; +import { EMPTY_STATE_ILLUSTRATION_SIZES } from './emptyStateIllustration.config'; + +interface HoldingsEmptyStateProps { + className?: string; + /** Creator discovery path — defaults to marketplace creators route. */ + browseHref?: string; +} + +/** + * Shown when the holdings query has settled with zero creator keys. + * Distinct from loading (skeleton) and from marketplace search empty states. + */ +const HoldingsEmptyState: React.FC = ({ + className, + browseHref = '/creators', +}) => ( +
+
+
+ + +
+ +

+ No creator keys yet +

+

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

+ + + Browse creators +
+); + +export default HoldingsEmptyState; diff --git a/src/components/common/KeyHolderList.tsx b/src/components/common/KeyHolderList.tsx new file mode 100644 index 00000000..0c30f106 --- /dev/null +++ b/src/components/common/KeyHolderList.tsx @@ -0,0 +1,60 @@ +import { formatHolderCount, formatPercent } from '@/utils/numberFormat.utils'; +import { rankKeyHolders, type KeyHolder } from '@/utils/keyHolderRanking.utils'; + +export interface KeyHolderListProps { + holders: KeyHolder[]; +} + +/** + * Ranks investors by key count and shows each holder's share of the total + * supply held across the list (#697). Sorting and rank/share math live in + * `rankKeyHolders` (utils/keyHolderRanking.utils.ts) so they're covered by + * pure-function unit tests independent of rendering. + */ +const KeyHolderList: React.FC = ({ holders }) => { + const ranked = rankKeyHolders(holders); + + if (ranked.length === 0) { + return ( +

+ No holders yet. +

+ ); + } + + return ( +
    + {ranked.map(holder => ( +
  1. +
    + + {holder.rank} + + {holder.displayName} +
    +
    + + {formatHolderCount(holder.keyCount)} keys + + + {formatPercent(holder.sharePercent, { maximumFractionDigits: 1 })} + +
    +
  2. + ))} +
+ ); +}; + +export default KeyHolderList; diff --git a/src/components/common/NotificationBell.tsx b/src/components/common/NotificationBell.tsx new file mode 100644 index 00000000..3cbefd94 --- /dev/null +++ b/src/components/common/NotificationBell.tsx @@ -0,0 +1,160 @@ +import { Bell } from 'lucide-react'; +import { useNavigate } from 'react-router'; +import { cn } from '@/lib/utils'; +import { useNotifications } from '@/hooks/useNotifications'; +import { formatRelativeTime } from '@/utils/time.utils'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; + +interface NotificationBellProps { + /** The authenticated user's id used as the cache key. */ + userId: string; + className?: string; +} + +/** + * Notification bell for the nav bar (issue #720). + * + * Shows an unread-count badge when count > 0, opens a dropdown of the five + * most recent notifications, marks items read on click, and provides a + * "View all" link to /notifications. + */ +export function NotificationBell({ userId, className = '' }: NotificationBellProps) { + const navigate = useNavigate(); + const { recent, unreadCount, isLoading, markAsRead } = useNotifications(userId); + + const handleNotificationClick = (notificationId: string, href: string) => { + markAsRead(notificationId); + void navigate(href); + }; + + return ( + + + + + + + + Notifications + {unreadCount > 0 && ( + + {unreadCount} unread + + )} + + + + + {isLoading && ( +
+ {Array.from({ length: 3 }).map((_, i) => ( +
+ ))} +
+ )} + + {!isLoading && recent.length === 0 && ( +

+ No notifications yet +

+ )} + + {!isLoading && + recent.map(notification => ( + + handleNotificationClick( + notification.id, + notification.href + ) + } + > +
+ {/* Unread indicator dot */} + {!notification.read && ( + + )} + + {notification.message} + +
+ + {formatRelativeTime(notification.createdAt)} + +
+ ))} + + + + void navigate('/notifications')} + > + View all + + + + ); +} + +export default NotificationBell; diff --git a/src/components/common/PriceSparkline.tsx b/src/components/common/PriceSparkline.tsx new file mode 100644 index 00000000..e26f8e32 --- /dev/null +++ b/src/components/common/PriceSparkline.tsx @@ -0,0 +1,90 @@ +import { cn } from '@/lib/utils'; + +interface PriceSparklineProps { + dataPoints: number[]; + width?: number; + height?: number; + className?: string; +} + +const POSITIVE_COLOR = '#34d399'; +const NEGATIVE_COLOR = '#ef4444'; +const NEUTRAL_COLOR = 'currentColor'; + +export function PriceSparkline({ + dataPoints, + width = 120, + height = 40, + className, +}: PriceSparklineProps) { + if (dataPoints.length === 0) return null; + + const getLineColor = () => { + if (dataPoints.length < 2) return NEUTRAL_COLOR; + const last = dataPoints[dataPoints.length - 1]; + const first = dataPoints[0]; + if (last > first) return POSITIVE_COLOR; + if (last < first) return NEGATIVE_COLOR; + return NEUTRAL_COLOR; + }; + + const lineColor = getLineColor(); + + const padding = 2; + const innerWidth = width - padding * 2; + const innerHeight = height - padding * 2; + + if (dataPoints.length === 1) { + return ( + + + + ); + } + + const min = Math.min(...dataPoints); + const max = Math.max(...dataPoints); + const range = max - min || 1; + + const buildPathD = () => + dataPoints + .map((value, index) => { + const x = padding + (index / (dataPoints.length - 1)) * innerWidth; + const y = + padding + (1 - (value - min) / range) * innerHeight; + return `${index === 0 ? 'M' : 'L'}${x.toFixed(1)} ${y.toFixed(1)}`; + }) + .join(' '); + + return ( + + + + ); +} diff --git a/src/components/common/SectionErrorBoundary.tsx b/src/components/common/SectionErrorBoundary.tsx index 97494039..b49f7d34 100644 --- a/src/components/common/SectionErrorBoundary.tsx +++ b/src/components/common/SectionErrorBoundary.tsx @@ -2,12 +2,24 @@ import { Component, type ErrorInfo, type ReactNode } from 'react'; import { AlertCircle, RefreshCw } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { cn } from '@/lib/utils'; +import { markErrorAsCaught } from '@/utils/globalErrorHandler.utils'; interface Props { children: ReactNode; sectionName?: string; minHeight?: string | number; className?: string; + /** + * Overrides the default "Something went wrong in this section" heading. + * Use for a short, section-specific message (e.g. "Chart unavailable — + * try refreshing"). + */ + title?: string; + /** + * Overrides the default explanatory paragraph. Pass an empty string to + * show only the title (no secondary copy). + */ + description?: string; } interface State { @@ -26,6 +38,7 @@ class SectionErrorBoundary extends Component { } public componentDidCatch(error: Error, errorInfo: ErrorInfo) { + markErrorAsCaught(error); console.error(`Uncaught error in section ${this.props.sectionName || 'Unknown'}:`, error, errorInfo); } @@ -48,14 +61,16 @@ class SectionErrorBoundary extends Component {

- Something went wrong in this section + {this.props.title ?? 'Something went wrong in this section'}

-

- {this.props.sectionName - ? `We encountered an error while loading the ${this.props.sectionName}.` - : 'We encountered an error while loading this content.'} - Please try again or contact support if the issue persists. -

+ {this.props.description !== '' && ( +

+ {this.props.description ?? + (this.props.sectionName + ? `We encountered an error while loading the ${this.props.sectionName}. Please try again or contact support if the issue persists.` + : 'We encountered an error while loading this content. Please try again or contact support if the issue persists.')} +

+ )}
- {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
+ {error ? ( +

{error.message}

+ ) : null} + {showAdBlockerSuggestion ? ( +

+ {WALLET_CONNECTION_AD_BLOCKER_MESSAGE} +

+ ) : null} +
+ ); + } + + const truncated = shortenAddress(address); + + return ( + + + + + +
+
+

+ Connected wallet +

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

+ {name} +

+ + {/* Stats row */} +
+ {/* Key price */} +
+ + {keyPrice} + + XLM +
+ + {/* Holder count */} +
+ + + {formatHolderCount(holderCount)} + +
+
+ + {/* CTA */} +
+ View profile + +
+ + ); +} + +/* ------------------------------------------------------------------ */ +/* Section */ +/* ------------------------------------------------------------------ */ + +export default function CreatorSpotlight() { + const headingRef = useRef(null); + const gridRef = useRef(null); + + const { data: creators, isLoading } = useQuery({ + queryKey: queryKeys.creators.list({ sort: 'supply-desc', limit: 3 }), + queryFn: () => courseService.getCourses({ sort: 'supply-desc', limit: 3 }), + }); + + useEffect(() => { + const observer = new IntersectionObserver( + entries => { + entries.forEach(entry => { + if (entry.isIntersecting) { + entry.target.classList.add('is-visible'); + observer.unobserve(entry.target); + } + }); + }, + { threshold: 0.1 } + ); + + if (headingRef.current) observer.observe(headingRef.current); + if (gridRef.current) observer.observe(gridRef.current); + + return () => observer.disconnect(); + }, []); + + // Hide section when no creators returned (AC: Section hidden when no creators) + if (!isLoading && (!creators || creators.length < 1)) { + return null; + } + + return ( +
+
+ {/* Header */} +
+
+ + + Creator Spotlight + +
+ +
+

+ + Most collected creators + +
+ + discover who has the biggest following. + +

+ + View all + + +
+
+ + {/* Grid or Skeleton */} + {isLoading ? ( +
+ +
+ ) : ( +
+ {creators?.map((creator, index) => ( + + ))} +
+ )} +
+
+ ); +} diff --git a/src/components/home/Header.tsx b/src/components/home/Header.tsx index 57452a12..f88f65e6 100644 --- a/src/components/home/Header.tsx +++ b/src/components/home/Header.tsx @@ -1,4 +1,7 @@ import { useEffect, useState } from 'react'; +import WalletStatusChip from '@/components/common/WalletStatusChip'; +import NotificationBell from '@/components/common/NotificationBell'; +import { useProfileStore } from '@/hooks/useProfileStore'; import { Link } from 'react-router'; const navLinks = [ @@ -9,6 +12,7 @@ const navLinks = [ export default function Header() { const [scrolled, setScrolled] = useState(false); + const profile = useProfileStore(state => state.profile); useEffect(() => { const onScroll = () => { @@ -65,17 +69,21 @@ export default function Header() { )} - {/* CTA */} - - Connect Wallet - + {/* Right-side actions: notification bell (#720) + wallet status chip (#686). + NotificationBell is only rendered when a user profile is available. */} +
+ {profile && ( + + )} + +
); 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/constants/stellar.ts b/src/constants/stellar.ts index 4178340e..71dc89e2 100644 --- a/src/constants/stellar.ts +++ b/src/constants/stellar.ts @@ -1,2 +1,36 @@ /** Stellar / Soroban native asset precision: 1 XLM = 10^7 stroops. */ export const STROOPS_PER_XLM = 10_000_000; + +/** Stellar Expert network identifiers. */ +export type StellarNetwork = 'mainnet' | 'testnet'; + +/** + * Builds a Stellar Expert transaction explorer URL. + * + * @param txHash - The full transaction hash. + * @param network - The Stellar network to link to ('mainnet' or 'testnet'). + * @returns The full Stellar Expert URL for the transaction. + */ +export function buildStellarExpertTxUrl( + txHash: string, + network: StellarNetwork +): string { + return `https://stellar.expert/explorer/${network}/tx/${txHash}`; +} + +/** + * Truncates a transaction hash for display purposes. + * + * @param txHash - The full transaction hash. + * @param prefixLen - Number of leading characters to keep (default 8). + * @param suffixLen - Number of trailing characters to keep (default 6). + * @returns A truncated string like `0x1a2b3c…ef1234`. + */ +export function truncateTxHash( + txHash: string, + prefixLen = 8, + suffixLen = 6 +): string { + if (txHash.length <= prefixLen + suffixLen + 1) return txHash; + return `${txHash.slice(0, prefixLen)}…${txHash.slice(-suffixLen)}`; +} 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__/optimisticRollbackSnapshotMissing.test.ts b/src/hooks/__tests__/optimisticRollbackSnapshotMissing.test.ts new file mode 100644 index 00000000..11809f34 --- /dev/null +++ b/src/hooks/__tests__/optimisticRollbackSnapshotMissing.test.ts @@ -0,0 +1,158 @@ +/** + * Warn-level log when an optimistic rollback cannot find its snapshot (#674). + */ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { renderHook, waitFor } from '@testing-library/react'; +import React from 'react'; +import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'; +import { useTradeMutation, type TradeVariables } from '../useWallet'; +import { queryKeys } from '@/lib/queryKeys'; + +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'; + +const BUY_VARIABLES: TradeVariables = { + creatorId: 'creator-a', + amount: 2, + priceStroops: 500_000, + price: 0.05, +}; + +const SELL_VARIABLES: TradeVariables = { + creatorId: 'creator-b', + amount: -2, + priceStroops: 500_000, + price: 0.05, +}; + +describe('useTradeMutation rollback snapshot-missing warning (#674)', () => { + let warnSpy: ReturnType; + const originalEnv = process.env.NODE_ENV; + + beforeEach(() => { + process.env.NODE_ENV = 'development'; + warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + }); + + afterEach(() => { + warnSpy.mockRestore(); + process.env.NODE_ENV = originalEnv; + }); + + it('emits a warn log with all required fields when onMutate fails to capture a snapshot', async () => { + const { wrapper, queryClient } = createWrapper(); + vi.spyOn(queryClient, 'cancelQueries').mockRejectedValueOnce( + new Error('cancelQueries failed') + ); + + const { result } = renderHook(() => useTradeMutation(address), { wrapper }); + + result.current.mutate(BUY_VARIABLES); + + await waitFor(() => expect(result.current.isError).toBe(true)); + + expect(warnSpy).toHaveBeenCalledWith( + '[optimistic-rollback]', + expect.objectContaining({ + cache_key: JSON.stringify(queryKeys.wallet.holdings(address)), + action: 'buy', + creator_id: 'creator-a', + reason: 'snapshot_missing', + failed_at: expect.any(String), + }) + ); + }); + + it('identifies action as sell for a negative trade amount', async () => { + const { wrapper, queryClient } = createWrapper(); + vi.spyOn(queryClient, 'cancelQueries').mockRejectedValueOnce( + new Error('cancelQueries failed') + ); + + const { result } = renderHook(() => useTradeMutation(address), { wrapper }); + + result.current.mutate(SELL_VARIABLES); + + await waitFor(() => expect(result.current.isError).toBe(true)); + + expect(warnSpy).toHaveBeenCalledWith( + '[optimistic-rollback]', + expect.objectContaining({ action: 'sell', creator_id: 'creator-b' }) + ); + }); + + it('includes failed_at as an ISO timestamp', async () => { + const { wrapper, queryClient } = createWrapper(); + vi.spyOn(queryClient, 'cancelQueries').mockRejectedValueOnce( + new Error('cancelQueries failed') + ); + + const { result } = renderHook(() => useTradeMutation(address), { wrapper }); + + result.current.mutate(BUY_VARIABLES); + + await waitFor(() => expect(result.current.isError).toBe(true)); + + const call = warnSpy.mock.calls.find(c => c[0] === '[optimistic-rollback]'); + const log = call?.[1] as Record; + expect(log.failed_at).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/); + }); + + it('does not warn when onMutate captures a snapshot normally', async () => { + const { wrapper, queryClient } = createWrapper(); + queryClient.setQueryData(queryKeys.wallet.holdings(address), [ + { creatorId: 'creator-a', quantity: 1, priceStroops: null, price: null, pending: false }, + ]); + + const { result } = renderHook(() => useTradeMutation(address), { wrapper }); + + result.current.mutate(BUY_VARIABLES); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + expect(warnSpy).not.toHaveBeenCalled(); + }); + + it('does not emit the warn log in test environment', async () => { + process.env.NODE_ENV = 'test'; + warnSpy.mockClear(); + + const { wrapper, queryClient } = createWrapper(); + vi.spyOn(queryClient, 'cancelQueries').mockRejectedValueOnce( + new Error('cancelQueries failed') + ); + + const { result } = renderHook(() => useTradeMutation(address), { wrapper }); + + result.current.mutate(BUY_VARIABLES); + + await waitFor(() => expect(result.current.isError).toBe(true)); + + expect(warnSpy).not.toHaveBeenCalled(); + }); +}); 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..9d8ce2c1 --- /dev/null +++ b/src/hooks/__tests__/tradeCacheInvalidation.test.ts @@ -0,0 +1,109 @@ +/** + * 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'; +import { queryKeys } from '@/lib/queryKeys'; + +vi.mock('@/utils/toast.util', () => ({ + default: { + message: vi.fn(), + success: vi.fn(), + error: vi.fn(), + loading: vi.fn(), + transactionSuccess: vi.fn(), + }, +})); + +function createWrapper(queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, +})) { + return { + queryClient, + Wrapper: 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: 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: 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(); + }); + + it('invalidates the marketplace list cache after a trade settles (#691)', async () => { + const { queryClient, Wrapper } = createWrapper(); + + queryClient.setQueryData(queryKeys.creators.infiniteList(undefined), { + pages: [{ items: [], page: 1, hasMore: false }], + pageParams: [1], + }); + + const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries'); + + const { result } = renderHook(() => useTradeMutation('GWALLET'), { wrapper: Wrapper }); + + result.current.mutate({ + creatorId: 'creator-3', + amount: 2, + priceStroops: 250_000, + price: 0.03, + }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true), { timeout: 3000 }); + + expect(invalidateSpy).toHaveBeenCalledWith( + expect.objectContaining({ queryKey: queryKeys.creators.all }), + ); + }); +}); diff --git a/src/hooks/__tests__/transactionFailureLog.test.ts b/src/hooks/__tests__/transactionFailureLog.test.ts new file mode 100644 index 00000000..c3b02ec1 --- /dev/null +++ b/src/hooks/__tests__/transactionFailureLog.test.ts @@ -0,0 +1,276 @@ +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 { + wrapper: ({ children }: { children: ReactNode }) => + React.createElement( + QueryClientProvider, + { client: queryClient }, + children + ), + queryClient, + }; +}; + +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, queryClient } = 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.spyOn(queryClient, 'cancelQueries').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, queryClient } = 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.spyOn(queryClient, 'cancelQueries').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, queryClient } = 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.spyOn(queryClient, 'cancelQueries').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, queryClient } = 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.spyOn(queryClient, 'cancelQueries').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, queryClient } = 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.spyOn(queryClient, 'cancelQueries').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, queryClient } = 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.spyOn(queryClient, 'cancelQueries').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, queryClient } = createWrapper(); + const { result } = renderHook( + () => useTradeMutation('GWALLET1234567890ABCDEFGHIJ'), + { wrapper } + ); + + const variables: TradeVariables = { + creatorId: 'creator-string-error', + amount: 1, + priceStroops: 1_000_000, + price: 0.1, + }; + + vi.spyOn(queryClient, 'cancelQueries').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__/useCreatorProfileCacheLog.test.ts b/src/hooks/__tests__/useCreatorProfileCacheLog.test.ts new file mode 100644 index 00000000..4484b003 --- /dev/null +++ b/src/hooks/__tests__/useCreatorProfileCacheLog.test.ts @@ -0,0 +1,209 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { renderHook } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import React from 'react'; +import { useCreatorProfileCacheLog } from '@/hooks/useCreatorProfileCacheLog'; +import { queryKeys } from '@/lib/queryKeys'; +import { logger } from '@/utils/logger'; + +// Spy on the logger so we can assert calls without touching console. +vi.mock('@/utils/logger', () => ({ + logger: { + debug: vi.fn(), + }, +})); + +const mockLoggerDebug = vi.mocked(logger.debug); + +// ─── helpers ───────────────────────────────────────────────────────────────── + +function makeWrapper(client: QueryClient) { + return function Wrapper({ children }: { children: React.ReactNode }) { + return React.createElement(QueryClientProvider, { client }, children); + }; +} + +/** + * Pre-populate a QueryClient cache entry so the hook sees existing data + * on mount (simulating a cache hit scenario). + */ +function seedCache( + client: QueryClient, + creatorId: string, + dataUpdatedAt: number +) { + // setQueryData puts data in the cache and updates dataUpdatedAt. + client.setQueryData(queryKeys.creatorProfile.byId(creatorId), { + id: creatorId, + title: 'Test Creator', + }); + // Manually adjust the state timestamp so data_age_ms is deterministic. + const cache = client.getQueryCache(); + const query = cache.find({ queryKey: queryKeys.creatorProfile.byId(creatorId) }); + if (query) { + // @ts-expect-error — directly patching internal state for test isolation. + query.state.dataUpdatedAt = dataUpdatedAt; + } +} + +// ───────────────────────────────────────────────────────────────────────────── + +describe('useCreatorProfileCacheLog', () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-01-01T00:00:00Z')); + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('emits a debug log with correct fields on cache hit', () => { + const client = new QueryClient(); + const creatorId = 'creator-abc'; + const dataUpdatedAt = Date.now() - 5_000; // data is 5 seconds old + + seedCache(client, creatorId, dataUpdatedAt); + + renderHook( + () => useCreatorProfileCacheLog(creatorId, false, true), + { wrapper: makeWrapper(client) } + ); + + expect(mockLoggerDebug).toHaveBeenCalledOnce(); + expect(mockLoggerDebug).toHaveBeenCalledWith('creator profile cache hit', { + creator_id: creatorId, + cache_status: 'hit', + data_age_ms: 5_000, + }); + }); + + it('data_age_ms reflects time since last successful fetch', () => { + const client = new QueryClient(); + const creatorId = 'creator-xyz'; + const ageMs = 12_345; + const dataUpdatedAt = Date.now() - ageMs; + + seedCache(client, creatorId, dataUpdatedAt); + + renderHook( + () => useCreatorProfileCacheLog(creatorId, false, true), + { wrapper: makeWrapper(client) } + ); + + const [, fields] = mockLoggerDebug.mock.calls[0]; + expect(fields).toMatchObject({ data_age_ms: ageMs }); + }); + + it('does not emit a log when a network fetch is in progress (isFetching=true)', () => { + const client = new QueryClient(); + const creatorId = 'creator-fetching'; + const dataUpdatedAt = Date.now() - 2_000; + + seedCache(client, creatorId, dataUpdatedAt); + + renderHook( + // isFetching=true means a network request is in flight + () => useCreatorProfileCacheLog(creatorId, true, true), + { wrapper: makeWrapper(client) } + ); + + expect(mockLoggerDebug).not.toHaveBeenCalled(); + }); + + it('does not emit a log when there is no successful data yet (isSuccess=false)', () => { + const client = new QueryClient(); + const creatorId = 'creator-loading'; + + renderHook( + // First load — no cached data exists + () => useCreatorProfileCacheLog(creatorId, true, false), + { wrapper: makeWrapper(client) } + ); + + expect(mockLoggerDebug).not.toHaveBeenCalled(); + }); + + it('emits the log only once per mount even if re-rendered multiple times', () => { + const client = new QueryClient(); + const creatorId = 'creator-rerender'; + const dataUpdatedAt = Date.now() - 1_000; + + seedCache(client, creatorId, dataUpdatedAt); + + const { rerender } = renderHook( + ({ fetching, success }: { fetching: boolean; success: boolean }) => + useCreatorProfileCacheLog(creatorId, fetching, success), + { + wrapper: makeWrapper(client), + initialProps: { fetching: false, success: true }, + } + ); + + // First render triggers the log. + expect(mockLoggerDebug).toHaveBeenCalledOnce(); + + // Re-render with same props — must not fire again. + rerender({ fetching: false, success: true }); + expect(mockLoggerDebug).toHaveBeenCalledOnce(); + + // Another re-render — still just once. + rerender({ fetching: false, success: true }); + expect(mockLoggerDebug).toHaveBeenCalledOnce(); + }); + + it('does not emit a log when cache entry has dataUpdatedAt=0 (never fetched from network)', () => { + const client = new QueryClient(); + const creatorId = 'creator-no-data-ts'; + + // Seed with dataUpdatedAt=0 to simulate an entry that was never + // populated from a real network response. + seedCache(client, creatorId, 0); + const cache = client.getQueryCache(); + const query = cache.find({ queryKey: queryKeys.creatorProfile.byId(creatorId) }); + if (query) { + // @ts-expect-error — directly patching internal state for test isolation. + query.state.dataUpdatedAt = 0; + } + + renderHook( + () => useCreatorProfileCacheLog(creatorId, false, true), + { wrapper: makeWrapper(client) } + ); + + expect(mockLoggerDebug).not.toHaveBeenCalled(); + }); + + it('emits a new log after the component remounts (new mount = new hasFiredRef)', () => { + const client = new QueryClient(); + const creatorId = 'creator-remount'; + const dataUpdatedAt = Date.now() - 3_000; + + seedCache(client, creatorId, dataUpdatedAt); + + const { unmount } = renderHook( + () => useCreatorProfileCacheLog(creatorId, false, true), + { wrapper: makeWrapper(client) } + ); + expect(mockLoggerDebug).toHaveBeenCalledOnce(); + + // Unmount and remount — hasFiredRef resets, so it should log again. + unmount(); + mockLoggerDebug.mockClear(); + + // Update the seed timestamp so data_age_ms is still valid after remount. + const cache = client.getQueryCache(); + const query = cache.find({ queryKey: queryKeys.creatorProfile.byId(creatorId) }); + if (query) { + // @ts-expect-error — directly patching internal state for test isolation. + query.state.dataUpdatedAt = Date.now() - 4_000; + } + + renderHook( + () => useCreatorProfileCacheLog(creatorId, false, true), + { wrapper: makeWrapper(client) } + ); + expect(mockLoggerDebug).toHaveBeenCalledOnce(); + }); +}); 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..377dba33 --- /dev/null +++ b/src/hooks/__tests__/useInfiniteCreatorMarketplace.test.ts @@ -0,0 +1,232 @@ +/** + * 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'; +import { queryKeys } from '@/lib/queryKeys'; + +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)); + }); + + describe('stale-while-revalidate (#691)', () => { + it('serves cached data instantly (no first-page loading state) on remount within 60s', async () => { + mockGetCoursesPage.mockResolvedValue({ + items: [makeCreator('a')], + page: 1, + hasMore: false, + }); + + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + const wrapper = ({ children }: { children: React.ReactNode }) => + React.createElement(QueryClientProvider, { client: queryClient }, children); + + const first = renderHook(() => useInfiniteCreatorMarketplace(), { wrapper }); + await waitFor(() => expect(first.result.current.isLoadingFirstPage).toBe(false)); + first.unmount(); + + mockGetCoursesPage.mockClear(); + + const second = renderHook(() => useInfiniteCreatorMarketplace(), { wrapper }); + + // Cached data must be available immediately -- never a spinner state + // -- because the previous fetch is still within the 60s staleTime. + expect(second.result.current.isLoadingFirstPage).toBe(false); + expect(second.result.current.creators).toHaveLength(1); + expect(mockGetCoursesPage).not.toHaveBeenCalled(); + }); + + it('reports isRefreshing while silently refetching stale data in the background', async () => { + mockGetCoursesPage.mockResolvedValue({ + items: [makeCreator('a')], + page: 1, + hasMore: false, + }); + + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + const wrapper = ({ children }: { children: React.ReactNode }) => + React.createElement(QueryClientProvider, { client: queryClient }, children); + + const { result } = renderHook(() => useInfiniteCreatorMarketplace(), { wrapper }); + await waitFor(() => expect(result.current.isLoadingFirstPage).toBe(false)); + + expect(result.current.isRefreshing).toBe(false); + + // Slow down the refetch so the transient "refreshing" window is + // actually observable instead of resolving synchronously. + let resolveRefetch!: (page: CoursesPage) => void; + mockGetCoursesPage.mockReturnValueOnce( + new Promise(resolve => { + resolveRefetch = resolve; + }) + ); + + // Force the cached entry to be considered stale, then trigger a + // background refetch the way a remount-after-60s would. + queryClient.invalidateQueries({ queryKey: queryKeys.creators.infiniteList(undefined) }); + + await waitFor(() => expect(result.current.isRefreshing).toBe(true)); + // Cached data must remain visible throughout -- no spinner regression. + expect(result.current.isLoadingFirstPage).toBe(false); + expect(result.current.creators).toHaveLength(1); + + resolveRefetch({ items: [makeCreator('a')], page: 1, hasMore: false }); + await waitFor(() => expect(result.current.isRefreshing).toBe(false)); + }); + + it('does not set isRefreshing during the initial load or next-page fetch', async () => { + let resolveFirstPage!: (page: CoursesPage) => void; + mockGetCoursesPage.mockReturnValueOnce( + new Promise(resolve => { + resolveFirstPage = resolve; + }) + ); + + const { result } = renderHook(() => useInfiniteCreatorMarketplace(), { + wrapper: createWrapper(), + }); + + expect(result.current.isRefreshing).toBe(false); + resolveFirstPage({ items: [makeCreator('a')], page: 1, hasMore: true }); + await waitFor(() => expect(result.current.isLoadingFirstPage).toBe(false)); + expect(result.current.isRefreshing).toBe(false); + + let resolveNextPage!: (page: CoursesPage) => void; + mockGetCoursesPage.mockReturnValueOnce( + new Promise(resolve => { + resolveNextPage = resolve; + }) + ); + + result.current.fetchNextPage(); + await waitFor(() => expect(result.current.isFetchingNextPage).toBe(true)); + expect(result.current.isRefreshing).toBe(false); + + resolveNextPage({ items: [makeCreator('b')], page: 2, hasMore: false }); + }); + }); +}); 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__/useNavigationTiming.test.ts b/src/hooks/__tests__/useNavigationTiming.test.ts new file mode 100644 index 00000000..d9fb690a --- /dev/null +++ b/src/hooks/__tests__/useNavigationTiming.test.ts @@ -0,0 +1,278 @@ +/** + * Unit tests for useNavigationTiming — logs TTFB/DCL/load-complete via the + * Navigation Timing API after each mount of the marketplace, creator + * profile, and portfolio pages (#693, #726). + */ +import { renderHook } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { useNavigationTiming } from '../useNavigationTiming'; + +function mockNavigationEntry(overrides: Partial = {}) { + const entry = { + requestStart: 10, + responseStart: 60, + startTime: 0, + domContentLoadedEventEnd: 200, + loadEventEnd: 350, + ...overrides, + } as PerformanceNavigationTiming; + + vi.spyOn(performance, 'getEntriesByType').mockReturnValue([entry]); + return entry; +} + +describe('useNavigationTiming', () => { + let originalReadyState: string; + + beforeEach(() => { + originalReadyState = document.readyState; + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllEnvs(); + Object.defineProperty(document, 'readyState', { + value: originalReadyState, + configurable: true, + }); + }); + + function setProd(value: boolean) { + vi.stubEnv('PROD', value); + } + + function setReadyState(value: DocumentReadyState) { + Object.defineProperty(document, 'readyState', { value, configurable: true }); + } + + it('does not log when not in production mode', () => { + setProd(false); + setReadyState('complete'); + mockNavigationEntry(); + const infoSpy = vi.spyOn(console, 'info').mockImplementation(() => {}); + + renderHook(() => useNavigationTiming('marketplace')); + + expect(infoSpy).not.toHaveBeenCalled(); + }); + + it('logs ttfb, dcl, load_complete, and page_name in production mode', () => { + setProd(true); + setReadyState('complete'); + mockNavigationEntry({ + requestStart: 10, + responseStart: 60, + startTime: 0, + domContentLoadedEventEnd: 200, + loadEventEnd: 350, + }); + const infoSpy = vi.spyOn(console, 'info').mockImplementation(() => {}); + + renderHook(() => useNavigationTiming('marketplace')); + + expect(infoSpy).toHaveBeenCalledWith('[page-load-perf]', { + page_name: 'marketplace', + ttfb: 50, + dcl: 200, + load_complete: 350, + }); + }); + + it('logs after the load event when the document has not finished loading yet', () => { + setProd(true); + setReadyState('loading'); + mockNavigationEntry(); + const infoSpy = vi.spyOn(console, 'info').mockImplementation(() => {}); + + renderHook(() => useNavigationTiming('creator_profile')); + + expect(infoSpy).not.toHaveBeenCalled(); + + window.dispatchEvent(new Event('load')); + + expect(infoSpy).toHaveBeenCalledWith( + '[page-load-perf]', + expect.objectContaining({ page_name: 'creator_profile' }) + ); + }); + + it('logs only once per mount even if the hook re-renders', () => { + setProd(true); + setReadyState('complete'); + mockNavigationEntry(); + const infoSpy = vi.spyOn(console, 'info').mockImplementation(() => {}); + + const { rerender } = renderHook(({ page }) => useNavigationTiming(page), { + initialProps: { page: 'marketplace' }, + }); + + rerender({ page: 'marketplace' }); + rerender({ page: 'marketplace' }); + + expect(infoSpy).toHaveBeenCalledTimes(1); + }); + + it('does nothing when no navigation timing entry is available', () => { + setProd(true); + setReadyState('complete'); + vi.spyOn(performance, 'getEntriesByType').mockReturnValue([]); + const infoSpy = vi.spyOn(console, 'info').mockImplementation(() => {}); + + renderHook(() => useNavigationTiming('marketplace')); + + expect(infoSpy).not.toHaveBeenCalled(); + }); +}); + +/** + * Issue #678: lock in the acceptance criteria for the four fields of the + * `[page-load-perf]` log — every field is present, every timing field is a + * non-negative number, the route field reflects the current page, and the + * log is not emitted when PerformanceNavigationTiming data is unavailable. + * + * The implementation uses semantic field names — + * `page_name` → the spec's `route` + * `ttfb` → the spec's `time_to_first_byte_ms` + * `dcl` → the spec's `dom_content_loaded_ms` + * `load_complete` → the spec's `load_event_ms` + * — to keep this log stable for downstream observability (dashboards/alerts), + * so the assertions below target the actual emitted schema. + */ +describe('useNavigationTiming (#678) — page-load-perf log acceptance criteria', () => { + let originalReadyState: string; + + beforeEach(() => { + originalReadyState = document.readyState; + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllEnvs(); + Object.defineProperty(document, 'readyState', { + value: originalReadyState, + configurable: true, + }); + }); + + function setProd(value: boolean) { + vi.stubEnv('PROD', value); + } + + function setReadyState(value: DocumentReadyState) { + Object.defineProperty(document, 'readyState', { value, configurable: true }); + } + + it('all four fields are present in the emitted log and correctly typed', () => { + setProd(true); + setReadyState('complete'); + mockNavigationEntry({ + requestStart: 25, + responseStart: 75, + startTime: 0, + domContentLoadedEventEnd: 180, + loadEventEnd: 325, + }); + const infoSpy = vi.spyOn(console, 'info').mockImplementation(() => {}); + + renderHook(() => useNavigationTiming('marketplace')); + + expect(infoSpy).toHaveBeenCalledTimes(1); + const [, payload] = infoSpy.mock.calls[0]; + + // All four fields are present on the emitted payload. + expect(payload).toEqual( + expect.objectContaining({ + page_name: expect.any(String), + ttfb: expect.any(Number), + dcl: expect.any(Number), + load_complete: expect.any(Number), + }) + ); + + // No unexpected fields leak into the payload (the log schema is fixed). + expect(Object.keys(payload as Record).sort()).toEqual( + ['dcl', 'load_complete', 'page_name', 'ttfb'] + ); + + // And the actual values extracted from the PerformanceNavigationTiming entry. + expect(payload).toEqual({ + page_name: 'marketplace', + ttfb: 50, // responseStart(75) - requestStart(25) + dcl: 180, // domContentLoadedEventEnd(180) - startTime(0) + load_complete: 325, // loadEventEnd(325) - startTime(0) + }); + }); + + it('each timing field is a non-negative number across a range of entry values', () => { + setProd(true); + setReadyState('complete'); + // Use a mix that exercises "almost instant" (single-tick) navigation through + // to a slow load — every value the hook computes must be a finite number ≥ 0. + mockNavigationEntry({ + requestStart: 100, + responseStart: 102, + startTime: 0, + domContentLoadedEventEnd: 4, + loadEventEnd: 12, + }); + const infoSpy = vi.spyOn(console, 'info').mockImplementation(() => {}); + + renderHook(() => useNavigationTiming('marketplace')); + + expect(infoSpy).toHaveBeenCalledTimes(1); + const [, payload] = infoSpy.mock.calls[0] as [ + string, + { ttfb: number; dcl: number; load_complete: number; page_name: string }, + ]; + + // Type-level: each timing field is a finite, non-negative number. + expect(Number.isFinite(payload.ttfb)).toBe(true); + expect(Number.isFinite(payload.dcl)).toBe(true); + expect(Number.isFinite(payload.load_complete)).toBe(true); + + expect(payload.ttfb).toBeGreaterThanOrEqual(0); + expect(payload.dcl).toBeGreaterThanOrEqual(0); + expect(payload.load_complete).toBeGreaterThanOrEqual(0); + }); + + it('route field matches the current route string passed to the hook', () => { + setProd(true); + setReadyState('complete'); + mockNavigationEntry(); + const infoSpy = vi.spyOn(console, 'info').mockImplementation(() => {}); + + // Marketplace route. + renderHook(() => useNavigationTiming('marketplace')); + expect(infoSpy).toHaveBeenLastCalledWith( + '[page-load-perf]', + expect.objectContaining({ page_name: 'marketplace' }) + ); + + // Creator profile route. + renderHook(() => useNavigationTiming('creator_profile')); + expect(infoSpy).toHaveBeenLastCalledWith( + '[page-load-perf]', + expect.objectContaining({ page_name: 'creator_profile' }) + ); + + // Portfolio route (#726). + renderHook(() => useNavigationTiming('portfolio')); + expect(infoSpy).toHaveBeenLastCalledWith( + '[page-load-perf]', + expect.objectContaining({ page_name: 'portfolio' }) + ); + }); + + it('does not emit a log when PerformanceNavigationTiming is unavailable', () => { + setProd(true); + setReadyState('complete'); + // Simulate Safari / older browsers, or timing-blocked environments, where + // `performance.getEntriesByType('navigation')` returns no entries. + vi.spyOn(performance, 'getEntriesByType').mockReturnValue([]); + const infoSpy = vi.spyOn(console, 'info').mockImplementation(() => {}); + + renderHook(() => useNavigationTiming('marketplace')); + + expect(infoSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/src/hooks/__tests__/useNotifications.test.ts b/src/hooks/__tests__/useNotifications.test.ts new file mode 100644 index 00000000..8493d3b2 --- /dev/null +++ b/src/hooks/__tests__/useNotifications.test.ts @@ -0,0 +1,175 @@ +import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'; +import { act, renderHook, waitFor } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import React from 'react'; +import { useNotifications } from '@/hooks/useNotifications'; +import type { NotificationsResponse } from '@/services/notification.service'; + +const USER_ID = 'user_abc123'; + +function makeResponse( + overrides: Partial = {} +): NotificationsResponse { + return { + notifications: [], + unreadCount: 0, + ...overrides, + }; +} + +function makeWrapper() { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false, gcTime: 0 } }, + }); + return ({ children }: { children: React.ReactNode }) => + React.createElement( + QueryClientProvider, + { client: queryClient }, + children + ); +} + +describe('useNotifications', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('returns isLoading=true before the query resolves', () => { + const fetchFn = vi.fn(() => new Promise(() => {})); + const { result } = renderHook( + () => useNotifications(USER_ID, fetchFn), + { wrapper: makeWrapper() } + ); + expect(result.current.isLoading).toBe(true); + }); + + it('returns empty recent and zero unreadCount while loading', () => { + const fetchFn = vi.fn(() => new Promise(() => {})); + const { result } = renderHook( + () => useNotifications(USER_ID, fetchFn), + { wrapper: makeWrapper() } + ); + expect(result.current.recent).toEqual([]); + expect(result.current.unreadCount).toBe(0); + }); + + it('returns resolved notifications and unreadCount', async () => { + const notifications = [ + { + id: 'n1', + type: 'new_follower' as const, + message: 'Alice started following you', + createdAt: new Date().toISOString(), + read: false, + href: '/creator/alice', + }, + ]; + const fetchFn = vi.fn().mockResolvedValue( + makeResponse({ notifications, unreadCount: 1 }) + ); + + const { result } = renderHook( + () => useNotifications(USER_ID, fetchFn), + { wrapper: makeWrapper() } + ); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + expect(result.current.recent).toHaveLength(1); + expect(result.current.recent[0].id).toBe('n1'); + expect(result.current.unreadCount).toBe(1); + }); + + it('caps recent at five notifications even when the API returns more', async () => { + const notifications = Array.from({ length: 8 }, (_, i) => ({ + id: `n${i}`, + type: 'new_follower' as const, + message: `Notification ${i}`, + createdAt: new Date().toISOString(), + read: false, + href: `/creator/${i}`, + })); + const fetchFn = vi.fn().mockResolvedValue( + makeResponse({ notifications, unreadCount: 8 }) + ); + + const { result } = renderHook( + () => useNotifications(USER_ID, fetchFn), + { wrapper: makeWrapper() } + ); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + expect(result.current.recent).toHaveLength(5); + }); + + it('does not fetch when userId is empty', () => { + const fetchFn = vi.fn(); + renderHook(() => useNotifications('', fetchFn), { + wrapper: makeWrapper(), + }); + expect(fetchFn).not.toHaveBeenCalled(); + }); + + it('applies an optimistic read update when markAsRead is called', async () => { + const notifications = [ + { + id: 'n1', + type: 'new_follower' as const, + message: 'Alice started following you', + createdAt: new Date().toISOString(), + read: false, + href: '/creator/alice', + }, + { + id: 'n2', + type: 'key_purchase' as const, + message: 'Bob bought your key', + createdAt: new Date().toISOString(), + read: false, + href: '/creator/bob', + }, + ]; + + // markAsRead hits the real notificationService — stub the module-level + // singleton so we can make it resolve without a real server. + vi.mock('@/services/notification.service', () => ({ + notificationService: { + getNotifications: vi.fn(), + markAsRead: vi.fn().mockResolvedValue(undefined), + }, + NotificationService: vi.fn(), + })); + + const fetchFn = vi.fn().mockResolvedValue( + makeResponse({ notifications, unreadCount: 2 }) + ); + + const { result } = renderHook( + () => useNotifications(USER_ID, fetchFn), + { wrapper: makeWrapper() } + ); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + expect(result.current.unreadCount).toBe(2); + + act(() => { + result.current.markAsRead('n1'); + }); + + // After the optimistic update the count should drop by 1. + await waitFor(() => expect(result.current.unreadCount).toBe(1)); + + // The mutated notification should now appear as read. + const n1 = result.current.recent.find(n => n.id === 'n1'); + expect(n1?.read).toBe(true); + + // The other notification remains unread. + const n2 = result.current.recent.find(n => n.id === 'n2'); + expect(n2?.read).toBe(false); + }); +}); 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/useCreatorActivityFeed.ts b/src/hooks/useCreatorActivityFeed.ts new file mode 100644 index 00000000..26025cbd --- /dev/null +++ b/src/hooks/useCreatorActivityFeed.ts @@ -0,0 +1,34 @@ +import { useQuery } from '@tanstack/react-query'; +import { queryKeys } from '@/lib/queryKeys'; +import type { CreatorActivityTrade } from '@/services/creatorActivity.service'; + +export interface CreatorActivityFeedResult { + trades: CreatorActivityTrade[]; + isLoading: boolean; + isError: boolean; +} + +/** + * Fetches a creator's public activity feed via React Query. + * Query key: ['creators', creatorId, 'activity'] + * + * The queryFn is injected as a parameter (matching useCreatorHolderCount's + * convention) so tests can supply a mock without module-level vi.mock() + * patching. + */ +export function useCreatorActivityFeed( + creatorId: string, + fetchCreatorActivity: (id: string) => Promise +): CreatorActivityFeedResult { + const { data, isLoading, isError } = useQuery({ + queryKey: queryKeys.creators.activity(creatorId), + queryFn: () => fetchCreatorActivity(creatorId), + enabled: !!creatorId, + }); + + return { + trades: data ?? [], + isLoading, + isError, + }; +} 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/useCreatorProfileCacheLog.ts b/src/hooks/useCreatorProfileCacheLog.ts new file mode 100644 index 00000000..f2741674 --- /dev/null +++ b/src/hooks/useCreatorProfileCacheLog.ts @@ -0,0 +1,60 @@ +import { useEffect, useRef } from 'react'; +import { useQueryClient } from '@tanstack/react-query'; +import { queryKeys } from '@/lib/queryKeys'; +import { logger } from '@/utils/logger'; + +/** + * Emits a debug-level cache-hit log when a creator profile query is + * served entirely from the React Query cache — i.e. the component + * mounted, data was already available, **and** no network fetch was + * triggered. + * + * The log includes: + * - `creator_id` — the queried creator. + * - `cache_status: 'hit'` — always `'hit'` when the log fires. + * - `data_age_ms` — milliseconds since the query was last successfully + * fetched from the network. + * + * The log fires **at most once per mount** to avoid duplicate entries on + * re-renders. It is suppressed when a network fetch is in progress or + * when the environment is `test` (handled inside `logger`). + * + * @param creatorId - The creator ID passed to the profile query. + * @param isFetching - `isFetching` from the corresponding `useQuery`. + * @param isSuccess - `isSuccess` from the corresponding `useQuery`. + */ +export function useCreatorProfileCacheLog( + creatorId: string, + isFetching: boolean, + isSuccess: boolean +): void { + const queryClient = useQueryClient(); + // Guard: fire at most once per mount regardless of re-renders. + const hasFiredRef = useRef(false); + + useEffect(() => { + // Already logged this mount — skip. + if (hasFiredRef.current) return; + // Data must be present and no network fetch in flight. + if (!isSuccess || isFetching) return; + + // Retrieve the full query state to read `dataUpdatedAt`. + const state = queryClient.getQueryState( + queryKeys.creatorProfile.byId(creatorId) + ); + + // `dataUpdatedAt === 0` means the cache entry was never populated by a + // network response — nothing useful to log. + if (!state || state.dataUpdatedAt === 0) return; + + const data_age_ms = Date.now() - state.dataUpdatedAt; + + logger.debug('creator profile cache hit', { + creator_id: creatorId, + cache_status: 'hit' as const, + data_age_ms, + }); + + hasFiredRef.current = true; + }, [creatorId, isFetching, isSuccess, queryClient]); +} diff --git a/src/hooks/useCreatorProfileQuery.ts b/src/hooks/useCreatorProfileQuery.ts new file mode 100644 index 00000000..0b75934d --- /dev/null +++ b/src/hooks/useCreatorProfileQuery.ts @@ -0,0 +1,25 @@ +import { useQuery } from '@tanstack/react-query'; +import { courseService, type Course } from '@/services/course.service'; +import { queryKeys } from '@/lib/queryKeys'; + +/** How long a fetched creator profile is considered fresh (30 seconds). */ +export const CREATOR_PROFILE_STALE_TIME_MS = 30_000; + +/** + * Fetches a single creator profile by ID via React Query. + * + * Data is considered fresh for {@link CREATOR_PROFILE_STALE_TIME_MS}ms, + * matching the TTL used by the underlying `courseService` cache layer. + * Subsequent mounts within that window are served from the React Query + * cache without a network request. + * + * @param creatorId - The creator's unique identifier. + */ +export function useCreatorProfileQuery(creatorId: string) { + return useQuery({ + queryKey: queryKeys.creatorProfile.byId(creatorId), + queryFn: () => courseService.getCourse(creatorId), + staleTime: CREATOR_PROFILE_STALE_TIME_MS, + enabled: Boolean(creatorId), + }); +} 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..141c4e23 --- /dev/null +++ b/src/hooks/useInfiniteCreatorMarketplace.ts @@ -0,0 +1,112 @@ +import { useInfiniteQuery } from '@tanstack/react-query'; +import { useEffect, useMemo, useRef } from 'react'; +import { courseService, type Course, type GetCoursesParams } from '@/services/course.service'; +import { queryKeys } from '@/lib/queryKeys'; + +const FIRST_PAGE = 1; + +// #691 — cached pages are served instantly and treated as fresh for this +// long; a background refetch only kicks in once data is older than this. +const MARKETPLACE_STALE_TIME_MS = 60_000; + +/** + * 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`. + * + * #691 — `staleTime` enables stale-while-revalidate: a cached list renders + * immediately (no spinner) on remount within 60s, while React Query silently + * refetches in the background once it's stale. `isRefreshing` distinguishes + * that silent background refetch from the initial (spinner-worthy) load. + */ +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), + staleTime: MARKETPLACE_STALE_TIME_MS, + }); + + // Track whether we've already logged a background refetch for the + // current fetch cycle so the debug log fires exactly once per refetch. + const hasLoggedRefetchRef = useRef(false); + + // Track page count to emit a debug log when a new page is fetched via scroll. + const pageCountRef = useRef(0); + + // Structured debug log on stale-while-revalidate background refetch (#595). + // Fires when isFetching transitions to true while isLoading is false, + // which means the data is being silently refreshed in the background + // rather than loading for the first time. Skipped in test environments. + useEffect(() => { + if (import.meta.env.MODE === 'test') return; + + if (query.isFetching && !query.isLoading) { + if (!hasLoggedRefetchRef.current) { + hasLoggedRefetchRef.current = true; + console.debug('[query-background-refetch]', { + query_key: queryKeys.creators.infiniteList(params), + stale_time_ms: 0, + refetch_triggered_at: new Date().toISOString(), + }); + } + } else { + hasLoggedRefetchRef.current = false; + } + }, [query.isFetching, query.isLoading, params]); + + // Structured debug log on infinite scroll next page fetch (#624). + // Emits when the data pages array grows beyond its previous length, + // ignoring the initial page load (when pageCountRef is 0). + useEffect(() => { + if (import.meta.env.MODE === 'test') return; + if (!query.data) return; + + const currentPages = query.data.pages; + // Log if page count increased and it wasn't the initial load + if (currentPages.length > pageCountRef.current && pageCountRef.current > 0) { + const lastPage = currentPages[currentPages.length - 1]; + console.debug('[infinite-scroll-next-page]', { + cursor: lastPage.page, + results_fetched: lastPage.items.length, + has_more: lastPage.hasMore, + fetched_at: new Date().toISOString(), + }); + } + pageCountRef.current = currentPages.length; + }, [query.data]); + + // 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, + // True only while a background revalidation is in flight after data + // has already been shown once — never true during the very first, + // spinner-worthy load (that's isLoadingFirstPage). + isRefreshing: query.isFetching && !query.isLoading && !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..c08b86a2 --- /dev/null +++ b/src/hooks/useNavigationTiming.ts @@ -0,0 +1,63 @@ +import { useEffect, useRef } from 'react'; + +export interface PageLoadTiming { + page_name: string; + ttfb: number; + dcl: number; + load_complete: number; +} + +function readNavigationTiming(pageName: string): PageLoadTiming | null { + const entries = performance.getEntriesByType('navigation'); + if (!entries.length) return null; + + const nav = entries[0] as PerformanceNavigationTiming; + + return { + page_name: pageName, + ttfb: Math.round(nav.responseStart - nav.requestStart), + dcl: Math.round(nav.domContentLoadedEventEnd - nav.startTime), + load_complete: Math.round(nav.loadEventEnd - nav.startTime), + }; +} + +/** + * Logs page-load performance (TTFB / DOM Content Loaded / load complete) via + * the Navigation Timing API after the page becomes interactive (#693, #726). + * + * Used on marketplace, creator profile, and portfolio pages with + * `page_name` of `'marketplace'`, `'creator_profile'`, or `'portfolio'`. + * + * - Production only (`import.meta.env.PROD`) — never fires in dev/test, so + * it can't add noise or overhead to local development. + * - Debounced to exactly one log per mount via a ref guard, so re-renders + * (state updates, prop changes) never produce duplicate logs. + * - Reads happen after `load` (or immediately if the page already finished + * loading by the time this effect runs) and never block rendering — the + * read + log is entirely async relative to the render path. + */ +export function useNavigationTiming(pageName: string) { + const hasLogged = useRef(false); + + useEffect(() => { + if (!import.meta.env.PROD) return; + if (hasLogged.current) return; + + function emit() { + if (hasLogged.current) return; + const timing = readNavigationTiming(pageName); + if (!timing) return; + + hasLogged.current = true; + console.info('[page-load-perf]', timing); + } + + if (document.readyState === 'complete') { + emit(); + return; + } + + window.addEventListener('load', emit, { once: true }); + return () => window.removeEventListener('load', emit); + }, [pageName]); +} diff --git a/src/hooks/useNotifications.ts b/src/hooks/useNotifications.ts new file mode 100644 index 00000000..2b1883e2 --- /dev/null +++ b/src/hooks/useNotifications.ts @@ -0,0 +1,91 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { queryKeys } from '@/lib/queryKeys'; +import { + notificationService, + type Notification, + type NotificationsResponse, +} from '@/services/notification.service'; + +const MAX_DROPDOWN_NOTIFICATIONS = 5; + +export interface UseNotificationsResult { + /** Up to five most recent notifications for the dropdown. */ + recent: Notification[]; + /** Total number of unread notifications. */ + unreadCount: number; + isLoading: boolean; + isError: boolean; + /** Mark a single notification as read by its id. */ + markAsRead: (notificationId: string) => void; +} + +/** + * Fetches the current user's notifications and exposes a helper to mark + * individual items as read with an optimistic update. + * + * The queryFn is injected so tests can supply a stub without module-level + * patching (matches the pattern used in `useCreatorActivityFeed`). + */ +export function useNotifications( + userId: string, + fetchNotifications: ( + id: string + ) => Promise = id => + notificationService.getNotifications(id) +): UseNotificationsResult { + const queryClient = useQueryClient(); + const queryKey = queryKeys.notifications.list(userId); + + const { data, isLoading, isError } = useQuery({ + queryKey, + queryFn: () => fetchNotifications(userId), + enabled: !!userId, + }); + + const { mutate: markAsRead } = useMutation({ + mutationFn: (notificationId: string) => + notificationService.markAsRead(notificationId), + // Optimistic update: flip the `read` flag and decrement the count + // so the badge responds instantly without waiting for the server. + onMutate: async (notificationId: string) => { + await queryClient.cancelQueries({ queryKey }); + + const previous = + queryClient.getQueryData(queryKey); + + if (previous) { + queryClient.setQueryData(queryKey, { + notifications: previous.notifications.map(n => + n.id === notificationId ? { ...n, read: true } : n + ), + unreadCount: Math.max(0, previous.unreadCount - 1), + }); + } + + return { previous }; + }, + onError: (_err, _id, context) => { + // Roll back the optimistic update on failure. + if (context?.previous) { + queryClient.setQueryData( + queryKey, + context.previous + ); + } + }, + onSettled: () => { + void queryClient.invalidateQueries({ queryKey }); + }, + }); + + const allNotifications = data?.notifications ?? []; + const recent = allNotifications.slice(0, MAX_DROPDOWN_NOTIFICATIONS); + + return { + recent, + unreadCount: data?.unreadCount ?? 0, + isLoading, + isError, + markAsRead, + }; +} 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..94f40d92 --- /dev/null +++ b/src/hooks/useWallet.ts @@ -0,0 +1,178 @@ +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) => { + const holdingsKey = queryKeys.wallet.holdings(address); + + if (context?.previousHoldings) { + queryClient.setQueryData(holdingsKey, context.previousHoldings); + } else if (process.env.NODE_ENV !== 'test') { + // No snapshot was captured in onMutate (e.g. it threw before + // returning), so the rollback above cannot run and the cache + // may be left holding the optimistic (unconfirmed) update. + console.warn('[optimistic-rollback]', { + cache_key: JSON.stringify(holdingsKey), + action: + (variables as TradeVariables).amount > 0 ? 'buy' : 'sell', + creator_id: (variables as TradeVariables).creatorId, + reason: 'snapshot_missing', + failed_at: new Date().toISOString(), + }); + } + + 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) => { + // #691 — a completed buy/sell changes supply/price data backing the + // marketplace list, so its cache must not wait out the 60s + // staleTime; invalidate immediately regardless of the trade outcome. + const invalidatedKeys = [ + queryKeys.wallet.holdings(address), + queryKeys.creators.all, + ]; + queryClient.invalidateQueries({ + queryKey: queryKeys.wallet.holdings(address), + }); + queryClient.invalidateQueries({ + queryKey: queryKeys.creators.all, + }); + + 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..567985ea --- /dev/null +++ b/src/lib/queryKeys.ts @@ -0,0 +1,37 @@ +/** + * Centralized React Query key factory. + * + * Using factory functions keeps key shapes consistent and makes it + * trivial to invalidate a whole family of queries (e.g. all creator + * profile queries with `queryClient.invalidateQueries({ queryKey: + * queryKeys.creatorProfile.all() })`). + */ + +import type { GetCoursesParams } from '@/services/course.service'; + +export const queryKeys = { + creatorProfile: { + all: () => ['creatorProfile'] as const, + byId: (creatorId: string) => ['creatorProfile', creatorId] as const, + }, + 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, + activity: (creatorId: string) => + ['creators', creatorId, 'activity'] as const, + }, + wallet: { + holdings: (address: string) => ['wallet', address, 'holdings'] as const, + activity: (address: string) => ['wallet', address, 'activity'] as const, + }, + notifications: { + all: () => ['notifications'] as const, + list: (userId: string) => ['notifications', userId, 'list'] as const, + }, +} 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..ccb60593 --- /dev/null +++ b/src/pages/CreatorDetailPage.tsx @@ -0,0 +1,90 @@ +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 CreatorActivityFeed from '@/components/common/CreatorActivityFeed'; +import { CreatorProfileHeaderSkeleton } from '@/components/common/CreatorSkeleton'; +import { bpsToPercent } from '@/utils/numberFormat.utils'; +import { resolveCreatorKeyPriceStroops } from '@/utils/keyPriceDisplay.utils'; +import CreatorPageErrorBoundary from '@/components/common/CreatorPageErrorBoundary'; +import { ApiError } from '@/services/api.service'; +import { useNavigationTiming } from '@/hooks/useNavigationTiming'; + +function CreatorDetailPageContent() { + const { id } = useParams<{ id: string }>(); + const { data: creator, isLoading, error } = useCreatorDetail(id || ''); + useNavigationTiming('creator_profile'); + + 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 +

+ +
+
+

+ Activity +

+ +
+
+
+ ); +} + +export default function CreatorDetailPage() { + return ( + + + + ); +} diff --git a/src/pages/HomePage.tsx b/src/pages/HomePage.tsx index 93da8775..cd62d618 100644 --- a/src/pages/HomePage.tsx +++ b/src/pages/HomePage.tsx @@ -2,14 +2,19 @@ import FAQ from '../components/home/FAQ'; import Footer from '../components/home/Footer'; import Header from '../components/home/Header'; import Hero from '../components/home/Hero'; +import CreatorSpotlight from '../components/home/CreatorSpotlight'; import TrendingCreators from '../components/home/TrendingCreators'; +import { useNavigationTiming } from '../hooks/useNavigationTiming'; export default function HomePage() { + useNavigationTiming('marketplace'); + return ( <>
+
diff --git a/src/pages/LandingPage.tsx b/src/pages/LandingPage.tsx index 9c26ce8b..03387f4d 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,20 @@ 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 { useAccount } from 'wagmi'; 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'; @@ -60,13 +67,21 @@ import { formatPortfolioValueDisplay, getPortfolioValueHelperText, } from '@/utils/portfolioValue.utils'; +import { + buildStellarExpertTxUrl, + truncateTxHash, +} from '@/constants/stellar'; +import { env } from '@/utils/env.utils'; import { usePrefersReducedMotion } from '@/hooks/usePrefersReducedMotion'; +import { useNavigationTiming } from '@/hooks/useNavigationTiming'; 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 +92,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 +188,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 +227,33 @@ 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(creator.id); + +type SortOption = 'featured' | 'price-asc' | 'price-desc' | 'supply-desc'; +type CreatorListMode = 'pagination' | 'infinite'; function LandingPage() { + useNavigationTiming('portfolio'); + 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 +264,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 +283,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 +323,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 +366,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 +423,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 +485,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 +533,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 +609,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 +645,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 +688,10 @@ function LandingPage() { }; const handleResetSearch = () => setSearchQuery(''); + const handleClearPriceFilters = () => { + setMinPriceFilter(''); + setMaxPriceFilter(''); + }; const handleRetryCreatorFetch = useCallback(() => { setFinalFetchError(''); @@ -646,20 +757,32 @@ function LandingPage() { handleRetryCreatorFetch(); }; + const { address: connectedAddress } = useAccount(); + const activeWalletAddress = connectedAddress || DEMO_WALLET_ADDRESS; + + const tradeMutation = useTradeMutation(activeWalletAddress); + const { data: cachedHoldings = [] } = useWalletHoldings(activeWalletAddress); + 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 defaultBaseQuantity = 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); + const baseQuantity = connectedAddress ? 0 : defaultBaseQuantity; + 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, connectedAddress] ); const portfolioValue = useMemo( () => calculatePortfolioValue(heldKeyPositions), @@ -679,42 +802,98 @@ 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; + } + + event.preventDefault(); + openTradeDialog('buy'); + }; + + window.addEventListener('keydown', handleTradeShortcut); + return () => window.removeEventListener('keydown', handleTradeShortcut); + }, [openTradeDialog]); + const handleCopyStellarAddress = async () => { try { - showToast.loading( - tradeSide === 'buy' - ? `Submitting buy for ${amount} key${amount === 1 ? '' : 's'}...` - : `Submitting sell for ${amount} key${amount === 1 ? '' : 's'}...` - ); + 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, 900)); + const handleConfirmTrade = async (amount: number) => { + setTradeSubmitting(true); - setFeaturedHoldings(current => - tradeSide === 'buy' - ? current + amount - : Math.max(0, current - amount) + // Simulated transaction hash — replace with the real hash once the + // on-chain mutation is wired up. + const txHash = + '0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890'; + const explorerUrl = buildStellarExpertTxUrl( + txHash, + env.VITE_STELLAR_NETWORK ); - await new Promise(resolve => window.setTimeout(resolve, 250)); - showToast.transactionSuccess( - 'Trade confirmed', - tradeSide === 'buy' - ? `Holdings refreshed: +${formatNumber(amount)} keys.` - : `Holdings refreshed: -${formatNumber(amount)} keys.` + 'Transaction confirmed', + truncateTxHash(txHash), + txHash, + explorerUrl ); + 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 +1003,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 -
{heldKeyPositions @@ -1092,12 +1434,21 @@ function LandingPage() { return (
{creator?.title ?? 'Unknown creator'}
+ {position.pending && ( + + + Pending + + )} {formatNumber(position.quantity)} keys ·{' '} {position.isPriceLoading ? 'Refreshing price' @@ -1150,7 +1501,7 @@ function LandingPage() { minHeight={300} > {finalFetchError ? ( - @@ -1195,11 +1546,12 @@ function LandingPage() { value="Verified creator" explanation="Creator has completed identity verification with Access Layer." /> - + Promise.resolve( + FEATURED_CREATOR_KEY_HOLDER_COUNT + ) } /> + {/* Issue 557: Stellar address with copy button */} +
+
+

+ Stellar Address +

+

+ {FEATURED_CREATOR_STELLAR_ADDRESS} +

+
+ +
{isNetworkMismatch && }
state.profile); + const userId = profile?.id ?? ''; + + const { recent, unreadCount, isLoading, markAsRead } = + useNotifications(userId); + + const handleNotificationClick = ( + notificationId: string, + href: string + ) => { + markAsRead(notificationId); + void navigate(href); + }; + + return ( +
+
+
+ + {isLoading && ( +
+ {Array.from({ length: 5 }).map((_, i) => ( +
+ ))} +
+ )} + + {!isLoading && recent.length === 0 && ( +
+
+
+

+ No notifications yet +

+
+ )} + + {!isLoading && recent.length > 0 && ( +
    + {recent.map(notification => ( +
  • + +
  • + ))} +
+ )} +
+ ); +} diff --git a/src/pages/__tests__/CreatorDetailPage.integration.test.tsx b/src/pages/__tests__/CreatorDetailPage.integration.test.tsx new file mode 100644 index 00000000..de648cbb --- /dev/null +++ b/src/pages/__tests__/CreatorDetailPage.integration.test.tsx @@ -0,0 +1,219 @@ +import type { ComponentProps, ReactNode } from 'react'; +import { act, render, screen } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { MemoryRouter, Routes, Route } from 'react-router'; +import CreatorDetailPage from '@/pages/CreatorDetailPage'; +import { courseService } from '@/services/course.service'; +import { ApiError } from '@/services/api.service'; +import { queryKeys } from '@/lib/queryKeys'; + +vi.mock('@/services/course.service', () => ({ + courseService: { + getCourse: vi.fn(), + }, +})); + +vi.mock('framer-motion', async () => { + const React = await import('react'); + type MotionProps = ComponentProps<'div'> & { + layout?: boolean; + transition?: unknown; + }; + + return { + AnimatePresence: ({ children }: { children: ReactNode }) => + React.createElement(React.Fragment, null, children), + LayoutGroup: ({ children }: { children: ReactNode }) => + React.createElement(React.Fragment, null, children), + motion: { + div: ({ children, ...props }: MotionProps) => { + const { layout, transition, ...divProps } = props; + void layout; + void transition; + return React.createElement('div', divProps, children); + }, + h1: ({ children, ...props }: ComponentProps<'h1'>) => + React.createElement('h1', props, children), + button: ({ children, ...props }: ComponentProps<'button'>) => + React.createElement('button', props, children), + }, + }; +}); + +const mockGetCourse = vi.mocked(courseService.getCourse); + +function makeFreshQueryClient() { + return new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); +} + +function createDeferred() { + let resolve!: (value: T | PromiseLike) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + + return { promise, resolve, reject }; +} + +describe('CreatorDetailPage Integration', () => { + let queryClient: QueryClient; + let consoleErrorSpy: ReturnType; + + beforeEach(() => { + queryClient = makeFreshQueryClient(); + mockGetCourse.mockReset(); + consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(() => { + vi.clearAllMocks(); + consoleErrorSpy.mockRestore(); + }); + + it('renders details, applies bpsToPercent, and formats fees as percentages', async () => { + mockGetCourse.mockResolvedValue({ + id: 'creator-123', + title: 'Alex Rivers', + description: 'Digital Artist & Illustrator', + price: 0.05, + priceStroops: 500_000, + creatorShareSupply: 120, + instructorId: 'arivers', + category: 'Art', + level: 'BEGINNER', + isVerified: true, + thumbnail: + 'https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?w=400&h=400&fit=crop', + creatorFeeBps: 500, // 5% + protocolFeeBps: 250, // 2.5% + }); + + render( + + + + } /> + + + + ); + + // Assert creator details render + expect( + await screen.findByText('Alex Rivers Profile') + ).toBeInTheDocument(); + expect( + screen.getByText('Digital Artist & Illustrator') + ).toBeInTheDocument(); + + // Assert fee labels are visible + expect(screen.getByText('Creator fee')).toBeInTheDocument(); + expect(screen.getByText('Protocol fee')).toBeInTheDocument(); + + // Assert percentage strings are displayed + expect(screen.getByText('5%')).toBeInTheDocument(); + expect(screen.getByText('2.5%')).toBeInTheDocument(); + + // Assert raw bps values are not visible in the rendered output + expect(screen.queryByText('500')).not.toBeInTheDocument(); + expect(screen.queryByText('250')).not.toBeInTheDocument(); + }); + + it('updates the displayed price after a background refetch without flashing a loading skeleton', async () => { + const initialCreator = { + id: 'creator-123', + title: 'Alex Rivers', + description: 'Digital Artist & Illustrator', + price: 100, + priceStroops: 1_000_000_000, + creatorShareSupply: 120, + instructorId: 'arivers', + category: 'Art', + level: 'BEGINNER' as const, + isVerified: true, + thumbnail: + 'https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?w=400&h=400&fit=crop', + creatorFeeBps: 500, + protocolFeeBps: 250, + }; + const updatedCreator = { + ...initialCreator, + price: 150, + priceStroops: 1_500_000_000, + }; + const refetchDeferred = createDeferred(); + + mockGetCourse + .mockResolvedValueOnce(initialCreator) + .mockImplementationOnce(() => refetchDeferred.promise); + + render( + + + + } /> + + + + ); + + expect(await screen.findByText('100.00 XLM')).toBeInTheDocument(); + expect(screen.queryByLabelText(/loading creator profile/i)).not.toBeInTheDocument(); + + await act(async () => { + void queryClient.invalidateQueries({ + queryKey: queryKeys.creators.detail('creator-123'), + }); + }); + + expect(screen.getByText('100.00 XLM')).toBeInTheDocument(); + expect(screen.queryByLabelText(/loading creator profile/i)).not.toBeInTheDocument(); + + refetchDeferred.resolve(updatedCreator); + + expect(await screen.findByText('150.00 XLM')).toBeInTheDocument(); + expect(screen.queryByText('100.00 XLM')).not.toBeInTheDocument(); + }); + + it('renders a creator-not-found state for a 404 response on the canonical /creator route', async () => { + mockGetCourse.mockRejectedValue( + new ApiError('Creator not found', 404, { + success: false, + message: 'Creator not found', + }) + ); + + render( + + + + } /> + Creators list
} /> + + + + ); + + expect( + await screen.findByRole('heading', { name: 'Creator not found' }) + ).toBeInTheDocument(); + expect( + screen.getByText(/we couldn't find a creator with that id/i) + ).toBeInTheDocument(); + expect( + screen.getByRole('link', { name: /back to creators/i }) + ).toHaveAttribute('href', '/creators'); + + expect( + screen.queryByLabelText(/loading creator profile/i) + ).not.toBeInTheDocument(); + expect( + screen.queryByText(/this creator page could not load/i) + ).not.toBeInTheDocument(); + }); +}); diff --git a/src/pages/__tests__/CreatorProfilePage.integration.test.tsx b/src/pages/__tests__/CreatorProfilePage.integration.test.tsx new file mode 100644 index 00000000..1bc509c2 --- /dev/null +++ b/src/pages/__tests__/CreatorProfilePage.integration.test.tsx @@ -0,0 +1,148 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import { MemoryRouter } from 'react-router'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import LandingPage from '@/pages/LandingPage'; +import { courseService, type Course } from '@/services/course.service'; + +vi.mock('@/hooks/useWallet', () => ({ + useTradeMutation: () => ({ mutateAsync: vi.fn(), isPending: false }), + useWalletHoldings: () => ({ data: [] }), +})); + +vi.mock('@/services/course.service', () => ({ + courseService: { + getCourses: vi.fn(), + }, +})); + +vi.mock('@/hooks/useNetworkMismatch', () => ({ + useNetworkMismatch: () => ({ + isMismatch: false, + expectedChainName: 'Stellar Testnet', + }), +})); + +vi.mock('@/hooks/useStaleData', () => ({ + useStaleData: () => ({ + stale: false, + ageMs: 0, + msUntilStale: 60000, + revalidate: vi.fn(), + }), +})); + +vi.mock('@/components/common/StellarConnectionQualityBadge', async () => { + const React = await import('react'); + return { + default: () => React.createElement('div', { role: 'status' }, 'RPC good'), + }; +}); + +vi.mock('@/components/common/CreatorCard', async () => { + const React = await import('react'); + return { + default: ({ creator }: { creator: { title: string } }) => + React.createElement('article', { 'aria-label': `Creator ${creator.title}` }, creator.title), + }; +}); + +vi.mock('framer-motion', async () => { + const React = await import('react'); + type MotionDivProps = { layout?: boolean; transition?: unknown; children?: React.ReactNode }; + return { + AnimatePresence: ({ children }: { children: React.ReactNode }) => + React.createElement(React.Fragment, null, children), + LayoutGroup: ({ children }: { children: React.ReactNode }) => + React.createElement(React.Fragment, null, children), + motion: { + div: ({ layout, transition, children, ...props }: MotionDivProps) => { + void layout; void transition; + return React.createElement('div', props, children); + }, + h1: (props: Record) => React.createElement('h1', props), + button: (props: Record) => React.createElement('button', props), + }, + }; +}); + +const mockMatchMedia = () => { + Object.defineProperty(window, 'matchMedia', { + writable: true, + value: vi.fn().mockImplementation((query: string) => ({ + matches: false, + media: query, + onchange: null, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + addListener: vi.fn(), + removeListener: vi.fn(), + dispatchEvent: vi.fn(), + })), + }); +}; + +const mockGetCourses = vi.mocked(courseService.getCourses); + +const seededCreator: Course = { + id: 'arivers', + title: 'Alex Rivers', + description: 'Digital Artist and Illustrator', + price: 0.05, + priceStroops: 500000, + creatorShareSupply: 120, + instructorId: 'arivers', + category: 'Art', + level: 'BEGINNER', + isVerified: true, +}; + +describe('Creator Profile Page - Integration', () => { + beforeEach(() => { + mockMatchMedia(); + window.localStorage.clear(); + window.sessionStorage.clear(); + mockGetCourses.mockReset(); + }); + + it('renders all expected creator profile sections with data', async () => { + mockGetCourses.mockResolvedValue([seededCreator]); + render( + + + + ); + + // Creator name visible + await waitFor(() => { + expect(screen.getByText('Alex Rivers')).toBeInTheDocument(); + }); + + // Current price visible (0.05 XLM from priceStroops 500000) + expect(screen.getByText('0.05 XLM')).toBeInTheDocument(); + + // Holder count visible ("3 keys" from featuredHoldings default) + expect(screen.getByText(/3 keys/)).toBeInTheDocument(); + + // Sparkline rendered (PriceSparkline component exists within the page) + // The PriceSparkline is inside CreatorCard which is mocked, but the profile + // section itself uses CreatorProfileInfoGrid with stats. + // Verify the profile info grid is present via a stat label + expect(screen.getByText('Membership')).toBeInTheDocument(); + + // Buy button visible and enabled + const buyButton = screen.getAllByRole('button', { name: /Buy/i }); + expect(buyButton.length).toBeGreaterThan(0); + expect(buyButton[0]).not.toBeDisabled(); + + // Sell button visible and enabled + const sellButton = screen.getAllByRole('button', { name: /Sell/i }); + expect(sellButton.length).toBeGreaterThan(0); + expect(sellButton[0]).not.toBeDisabled(); + + // No skeleton states present + expect(screen.queryByTestId('skeleton')).not.toBeInTheDocument(); + + // No error state present + expect(screen.queryByText(/unable to load/i)).not.toBeInTheDocument(); + }); +}); diff --git a/src/pages/__tests__/LandingPage.apiErrorToast.integration.test.tsx b/src/pages/__tests__/LandingPage.apiErrorToast.integration.test.tsx new file mode 100644 index 00000000..a34c9191 --- /dev/null +++ b/src/pages/__tests__/LandingPage.apiErrorToast.integration.test.tsx @@ -0,0 +1,143 @@ +import type { ComponentProps, ReactNode } from 'react'; +import { render, screen, waitFor } from '@testing-library/react'; +import { MemoryRouter } from 'react-router'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import LandingPage from '@/pages/LandingPage'; +import { courseService } from '@/services/course.service'; +import showToast from '@/utils/toast.util'; + +vi.mock('@/hooks/useWallet', () => ({ + useTradeMutation: () => ({ mutateAsync: vi.fn(), isPending: false }), + useWalletHoldings: () => ({ data: [] }), +})); + +vi.mock('@/services/course.service', () => ({ + courseService: { getCourses: vi.fn() }, +})); + +vi.mock('@/utils/toast.util', () => ({ + default: { + message: vi.fn(), + success: vi.fn(), + error: vi.fn(), + loading: vi.fn(), + transactionSuccess: vi.fn(), + }, +})); + +vi.mock('@/hooks/useNetworkMismatch', () => ({ + useNetworkMismatch: () => ({ + isMismatch: false, + expectedChainName: 'Stellar Testnet', + }), +})); + +vi.mock('@/hooks/useStaleData', () => ({ + useStaleData: () => ({ + stale: false, + ageMs: 0, + msUntilStale: 60_000, + revalidate: vi.fn(), + }), +})); + +vi.mock('@/components/common/StellarConnectionQualityBadge', async () => { + const React = await import('react'); + + return { + default: () => React.createElement('div', { role: 'status' }, 'RPC good'), + }; +}); + +vi.mock('@/components/common/CreatorCard', async () => { + const React = await import('react'); + + return { + default: ({ creator }: { creator: { title: string } }) => + React.createElement( + 'article', + { 'aria-label': `Creator ${creator.title}` }, + creator.title + ), + }; +}); + +vi.mock('framer-motion', async () => { + const React = await import('react'); + type MotionDivProps = ComponentProps<'div'> & { + layout?: boolean; + transition?: unknown; + }; + + return { + AnimatePresence: ({ children }: { children: ReactNode }) => + React.createElement(React.Fragment, null, children), + LayoutGroup: ({ children }: { children: ReactNode }) => + React.createElement(React.Fragment, null, children), + motion: { + div: ({ children, ...props }: MotionDivProps) => { + const { layout, transition, ...divProps } = props; + void layout; + void transition; + + return React.createElement('div', divProps, children); + }, + h1: ({ children, ...props }: ComponentProps<'h1'>) => + React.createElement('h1', props, children), + button: ({ children, ...props }: ComponentProps<'button'>) => + React.createElement('button', props, children), + }, + }; +}); + +const mockGetCourses = vi.mocked(courseService.getCourses); +const mockShowToast = vi.mocked(showToast); + +const mockMatchMedia = () => { + Object.defineProperty(window, 'matchMedia', { + writable: true, + value: vi.fn().mockImplementation((query: string) => ({ + matches: false, + media: query, + onchange: null, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + addListener: vi.fn(), + removeListener: vi.fn(), + dispatchEvent: vi.fn(), + })), + }); +}; + +describe('LandingPage API error toast integration (#495)', () => { + beforeEach(() => { + mockMatchMedia(); + window.localStorage.clear(); + window.sessionStorage.clear(); + mockGetCourses.mockReset(); + vi.clearAllMocks(); + }); + + it('shows a readable error toast and a nonblank fallback state when creator fetch fails', async () => { + mockGetCourses.mockRejectedValue( + new Error('Request failed with status 500') + ); + render( + + + + ); + + await waitFor(() => expect(mockShowToast.error).toHaveBeenCalledTimes(1)); + expect(mockShowToast.error).toHaveBeenCalledWith( + 'Unable to load creators. Check your connection and try again.' + ); + expect(mockShowToast.error.mock.calls[0]?.[0]).not.toContain( + '[object Object]' + ); + expect( + await screen.findByText(/loading live creators/i) + ).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /try again/i })).toBeEnabled(); + }); +}); diff --git a/src/pages/__tests__/LandingPage.buyFlowEndToEnd.integration.test.tsx b/src/pages/__tests__/LandingPage.buyFlowEndToEnd.integration.test.tsx new file mode 100644 index 00000000..44534a8f --- /dev/null +++ b/src/pages/__tests__/LandingPage.buyFlowEndToEnd.integration.test.tsx @@ -0,0 +1,361 @@ +/** + * End-to-end integration test for the buy flow (#642): from quantity input + * through simulated on-chain confirmation to the success toast and updated + * holdings cache. + * + * Mirrors the sell-flow E2E structure (see LandingPage.sellFlow.integration.test.tsx, + * #644): the wallet layer is the app's real demo wallet (react-query + + * useWallet, no hook mocks), so the holdings assertion exercises the real + * cache update path instead of a mocked one. Trading in this app is scoped + * to a single featured-creator panel (there is no per-card trade UI, and + * CreatorDetailPage has no trade panel), so "connect a mock wallet" here + * means rendering with a fresh QueryClient — the app's demo wallet address + * is used automatically and starts the featured creator at its baseline + * holdings. + */ +import type { ComponentProps, ReactNode } from 'react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { MemoryRouter } from 'react-router'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import LandingPage from '@/pages/LandingPage'; +import { courseService, type Course } from '@/services/course.service'; +import showToast from '@/utils/toast.util'; + +vi.mock('@/services/course.service', () => ({ + courseService: { getCourses: vi.fn() }, +})); + +vi.mock('@/utils/toast.util', () => ({ + default: { + message: vi.fn(), + success: vi.fn(), + error: vi.fn(), + loading: vi.fn(), + transactionSuccess: vi.fn(), + }, +})); + +vi.mock('@/hooks/useNetworkMismatch', () => ({ + useNetworkMismatch: () => ({ + isMismatch: false, + expectedChainName: 'Stellar Testnet', + }), +})); + +vi.mock('@/hooks/useStaleData', () => ({ + useStaleData: () => ({ + stale: false, + ageMs: 0, + msUntilStale: 60_000, + revalidate: vi.fn(), + }), +})); + +vi.mock('@/components/common/StellarConnectionQualityBadge', async () => { + const React = await import('react'); + + return { + default: () => React.createElement('div', { role: 'status' }, 'RPC good'), + }; +}); + +vi.mock('@/components/common/CreatorCard', async () => { + const React = await import('react'); + + return { + default: ({ creator }: { creator: { title: string } }) => + React.createElement( + 'article', + { 'aria-label': `Creator ${creator.title}` }, + creator.title + ), + }; +}); + +vi.mock('@/components/common/FeaturedCreatorAudienceChip', async () => { + const React = await import('react'); + + return { + FeaturedCreatorAudienceChip: () => + React.createElement('div', { 'data-testid': 'mock-audience-chip' }), + }; +}); + +vi.mock('framer-motion', async () => { + const React = await import('react'); + type MotionDivProps = ComponentProps<'div'> & { + layout?: boolean; + transition?: unknown; + }; + + return { + AnimatePresence: ({ children }: { children: ReactNode }) => + React.createElement(React.Fragment, null, children), + LayoutGroup: ({ children }: { children: ReactNode }) => + React.createElement(React.Fragment, null, children), + motion: { + div: ({ children, ...props }: MotionDivProps) => { + const { layout, transition, ...divProps } = props; + void layout; + void transition; + + return React.createElement('div', divProps, children); + }, + h1: ({ children, ...props }: ComponentProps<'h1'>) => + React.createElement('h1', props, children), + button: ({ children, ...props }: ComponentProps<'button'>) => + React.createElement('button', props, children), + }, + }; +}); + +const mockGetCourses = vi.mocked(courseService.getCourses); +const mockShowToast = vi.mocked(showToast); + +const featuredCreatorOnly: Course[] = [ + { + id: '1', + title: 'Alex Rivers', + description: 'Digital Artist & Illustrator', + price: 0.05, + priceStroops: 500_000, + creatorShareSupply: 120, + instructorId: '1', + category: 'Art', + level: 'BEGINNER', + isVerified: true, + }, +]; + +const mockMatchMedia = () => { + Object.defineProperty(window, 'matchMedia', { + writable: true, + value: vi.fn().mockImplementation((query: string) => ({ + matches: false, + media: query, + onchange: null, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + addListener: vi.fn(), + removeListener: vi.fn(), + dispatchEvent: vi.fn(), + })), + }); +}; + +const installStorageStub = (property: 'localStorage' | 'sessionStorage') => { + const store = new Map(); + Object.defineProperty(window, property, { + configurable: true, + writable: true, + value: { + getItem: (key: string) => store.get(String(key)) ?? null, + setItem: (key: string, value: string) => { + store.set(String(key), String(value)); + }, + removeItem: (key: string) => { + store.delete(String(key)); + }, + clear: () => store.clear(), + key: (index: number) => Array.from(store.keys())[index] ?? null, + get length() { + return store.size; + }, + }, + }); +}; + +const renderLandingPage = () => + render( + + + + + + ); + +describe('LandingPage buy flow end-to-end (#642)', () => { + beforeEach(() => { + mockMatchMedia(); + installStorageStub('localStorage'); + installStorageStub('sessionStorage'); + mockGetCourses.mockReset(); + vi.clearAllMocks(); + mockGetCourses.mockResolvedValue(featuredCreatorOnly); + }); + + afterEach(() => { + cleanup(); + }); + + it('completes the buy flow from quantity input to success toast and updated holdings cache', async () => { + renderLandingPage(); + + // Wallet connected; the local display baseline shows 3 keys until the + // react-query holdings cache (which starts empty) is written to. + await screen.findByText('3 keys · 0.05 XLM'); + + // Open the trade panel on the buy side + const [buyButton] = screen.getAllByRole('button', { name: 'Buy' }); + fireEvent.click(buyButton); + + // Enter quantity 1 + const amountInput = await screen.findByTestId('trade-dialog-amount'); + fireEvent.change(amountInput, { target: { value: '1' } }); + + // Submit and wait through the simulated on-chain confirmation + fireEvent.click(screen.getByTestId('trade-dialog-confirm')); + + await waitFor( + () => + expect(mockShowToast.transactionSuccess).toHaveBeenCalledWith( + 'Trade confirmed', + 'Holdings refreshed: +1 keys.' + ), + { timeout: 5000 } + ); + + // Holdings cache reflects the additional key (3 -> 4) + await waitFor( + () => expect(screen.getByText('4 keys · 0.05 XLM')).toBeInTheDocument(), + { timeout: 5000 } + ); + expect(screen.queryByText('3 keys · 0.05 XLM')).toBeNull(); + + // No error state at any stage of the flow + expect(mockShowToast.error).not.toHaveBeenCalled(); + }); + + it('reports the submitted quantity while the transaction is pending', async () => { + renderLandingPage(); + await screen.findByText('3 keys · 0.05 XLM'); + + const [buyButton] = screen.getAllByRole('button', { name: 'Buy' }); + fireEvent.click(buyButton); + fireEvent.change(await screen.findByTestId('trade-dialog-amount'), { + target: { value: '1' }, + }); + fireEvent.click(screen.getByTestId('trade-dialog-confirm')); + + expect(mockShowToast.loading).toHaveBeenCalledWith( + 'Submitting buy for 1 key...' + ); + }); + + it('accepts quantity 1 as a valid buy amount with no validation error', async () => { + renderLandingPage(); + await screen.findByText('3 keys · 0.05 XLM'); + + const [buyButton] = screen.getAllByRole('button', { name: 'Buy' }); + fireEvent.click(buyButton); + + const amountInput = await screen.findByTestId('trade-dialog-amount'); + fireEvent.change(amountInput, { target: { value: '1' } }); + + const confirmButton = screen.getByTestId('trade-dialog-confirm'); + expect(confirmButton).not.toBeDisabled(); + }); + + it('displays a pending indicator during the buy and clears it after confirmation (#672)', async () => { + renderLandingPage(); + await screen.findByText('3 keys · 0.05 XLM'); + + // Open the trade panel on the buy side and enter the quantity. + const [buyButton] = screen.getAllByRole('button', { name: 'Buy' }); + fireEvent.click(buyButton); + const amountInput = await screen.findByTestId('trade-dialog-amount'); + fireEvent.change(amountInput, { target: { value: '1' } }); + const confirmButton = screen.getByTestId('trade-dialog-confirm'); + + // Submit. AC #1: a pending indicator is visible on the dialog the + // instant the click handler sets `tradeSubmitting = true`. The + // loading state mirrors the synchronous loading-toast assertion above + // (both fire before the first `await`). + fireEvent.click(confirmButton); + + expect(confirmButton).toBeDisabled(); + expect(confirmButton).toHaveAttribute('aria-busy', 'true'); + // StableButtonContent renders BOTH the idle and loading slots in the + // DOM (so the button width stays stable across the swap) and toggles + // CSS visibility via the `invisible` Tailwind class. jest-dom's + // `.toBeVisible()` honors `visibility: hidden` (and `aria-hidden`), + // so it correctly reflects what the user perceives. + expect(screen.getByText('Submitting…')).toBeVisible(); + expect(screen.getByText('Confirm buy')).not.toBeVisible(); + // The loading toast announces the in-flight submission right away. + expect(mockShowToast.loading).toHaveBeenCalledWith( + 'Submitting buy for 1 key...' + ); + // The optimistic-update "Pending" pill on the holdings row is the + // second observable pending signal during the same in-flight window. + // (react-query's onMutate flips cache `pending: true` synchronously + // and RTL's `fireEvent` flushes the re-render via act(), but we still + // poll here for consistency with the file's waitFor idiom.) + await waitFor( + () => + expect( + screen.getAllByText('Pending', { exact: true }).length + ).toBeGreaterThan(0), + { timeout: 1000 } + ); + // No error toast during the in-flight window. + expect(mockShowToast.error).not.toHaveBeenCalled(); + + // Wait for the simulated on-chain confirmation to land. + // AC #3: success toast is visible after confirmation. + await waitFor( + () => + expect(mockShowToast.transactionSuccess).toHaveBeenCalledWith( + 'Trade confirmed', + 'Holdings refreshed: +1 keys.' + ), + { timeout: 5000 } + ); + + // AC #2: pending indicator absent after confirmation. Two surfaces + // carried a pending signal — the dialog (now unmounted) and the + // holdings row's badge. Both must be gone: dialog unmount proves the + // submit control unmounted, exact-match on the holdings pill proves + // the optimistic-update path also settled. + await waitFor( + () => + expect( + screen.queryByTestId('trade-dialog-confirm') + ).not.toBeInTheDocument(), + { timeout: 5000 } + ); + await waitFor( + () => + expect( + screen.queryByText('Pending', { exact: true }) + ).not.toBeInTheDocument(), + { timeout: 5000 } + ); + + // Holdings cache reflects the additional key. + await waitFor( + () => expect(screen.getByText('4 keys · 0.05 XLM')).toBeInTheDocument(), + { timeout: 5000 } + ); + expect(screen.queryByText('3 keys · 0.05 XLM')).toBeNull(); + + // AC #4: the submit button is re-enabled after confirmation. We + // cannot observe the original button (it unmounted with the dialog), + // so re-open the trade dialog: a fresh `` proves the parent state was fully reset. + // If `tradeSubmitting` had been left `true`, the reopened confirm + // would still report `aria-busy` and the "Submitting…" label would + // still be visible. + const [buyButtonAgain] = screen.getAllByRole('button', { name: 'Buy' }); + fireEvent.click(buyButtonAgain); + + const reopenedConfirm = await screen.findByTestId('trade-dialog-confirm'); + expect(reopenedConfirm).not.toBeDisabled(); + expect(reopenedConfirm).not.toHaveAttribute('aria-busy'); + expect(screen.getByText('Confirm buy')).toBeVisible(); + expect(screen.getByText('Submitting…')).not.toBeVisible(); + }); +}); diff --git a/src/pages/__tests__/LandingPage.cardCount.integration.test.tsx b/src/pages/__tests__/LandingPage.cardCount.integration.test.tsx new file mode 100644 index 00000000..47ee5508 --- /dev/null +++ b/src/pages/__tests__/LandingPage.cardCount.integration.test.tsx @@ -0,0 +1,220 @@ +import type { ComponentProps, ReactNode } from 'react'; +import { render, screen, waitFor } from '@testing-library/react'; +import { MemoryRouter } from 'react-router'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import LandingPage from '@/pages/LandingPage'; +import { courseService, type Course } from '@/services/course.service'; + +vi.mock('@/services/course.service', () => ({ + courseService: { getCourses: vi.fn() }, +})); + +vi.mock('@/hooks/useWallet', () => ({ + useTradeMutation: () => ({ mutateAsync: vi.fn(), isPending: false }), + useWalletHoldings: () => ({ data: [] }), +})); + +vi.mock('@/hooks/useNetworkMismatch', () => ({ + useNetworkMismatch: () => ({ + isMismatch: false, + expectedChainName: 'Stellar Testnet', + }), +})); + +vi.mock('@/hooks/useStaleData', () => ({ + useStaleData: () => ({ + stale: false, + ageMs: 0, + msUntilStale: 60_000, + revalidate: vi.fn(), + }), +})); + +vi.mock('@/components/common/StellarConnectionQualityBadge', async () => { + const React = await import('react'); + return { + default: () => React.createElement('div', { role: 'status' }, 'RPC good'), + }; +}); + +vi.mock('@/components/common/FeaturedCreatorAudienceChip', async () => { + const React = await import('react'); + return { + FeaturedCreatorAudienceChip: () => + React.createElement('div', null, 'Mocked Audience Chip'), + }; +}); + +vi.mock('@/components/common/CreatorCard', async () => { + const React = await import('react'); + return { + default: ({ creator }: { creator: { title: string } }) => + React.createElement( + 'article', + { 'data-testid': `creator-card-${creator.title}` }, + creator.title + ), + }; +}); + +vi.mock('framer-motion', async () => { + const React = await import('react'); + type MotionDivProps = ComponentProps<'div'> & { + layout?: boolean; + transition?: unknown; + }; + + return { + AnimatePresence: ({ children }: { children: ReactNode }) => + React.createElement(React.Fragment, null, children), + LayoutGroup: ({ children }: { children: ReactNode }) => + React.createElement(React.Fragment, null, children), + motion: { + div: ({ children, ...props }: MotionDivProps) => { + const { layout, transition, ...divProps } = props; + void layout; + void transition; + return React.createElement('div', divProps, children); + }, + h1: ({ children, ...props }: ComponentProps<'h1'>) => + React.createElement('h1', props, children), + button: ({ children, ...props }: ComponentProps<'button'>) => + React.createElement('button', props, children), + }, + }; +}); + +const mockGetCourses = vi.mocked(courseService.getCourses); + +const createMockCreators = (count: number): Course[] => { + return Array.from({ length: count }, (_, i) => ({ + id: `creator-${i + 1}`, + title: `Creator ${i + 1}`, + description: `Creator description ${i + 1}`, + price: 0.05, + priceStroops: 500_000, + creatorShareSupply: 100, + instructorId: `creator-id-${i + 1}`, + category: 'Art', + level: 'BEGINNER' as const, + isVerified: true, + })); +}; + +const renderWithProviders = (component: ReactNode) => { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + }, + }); + + return render( + + {component} + + ); +}; + +describe('LandingPage - Creator Card Count Integration', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('renders exactly 8 creator cards when API returns 8 creators', async () => { + const mockCreators = createMockCreators(8); + mockGetCourses.mockResolvedValue(mockCreators); + + renderWithProviders(); + + await waitFor(() => { + const cards = screen.getAllByTestId(/^creator-card-/); + expect(cards).toHaveLength(8); + }); + }); + + it('renders exactly 3 creator cards when API returns 3 creators', async () => { + const mockCreators = createMockCreators(3); + mockGetCourses.mockResolvedValue(mockCreators); + + renderWithProviders(); + + await waitFor(() => { + const cards = screen.getAllByTestId(/^creator-card-/); + expect(cards).toHaveLength(3); + }); + }); + + it('renders exactly 6 creator cards when API returns 6 creators', async () => { + const mockCreators = createMockCreators(6); + mockGetCourses.mockResolvedValue(mockCreators); + + renderWithProviders(); + + await waitFor(() => { + const cards = screen.getAllByTestId(/^creator-card-/); + expect(cards).toHaveLength(6); + }); + }); + + it('does not have skeleton cards after data loads with 8 creators', async () => { + const mockCreators = createMockCreators(8); + mockGetCourses.mockResolvedValue(mockCreators); + + renderWithProviders(); + + await waitFor(() => { + const cards = screen.getAllByTestId(/^creator-card-/); + expect(cards).toHaveLength(8); + // Verify no skeleton elements remain + const skeletons = screen.queryAllByTestId(/skeleton/i); + expect(skeletons).toHaveLength(0); + }); + }); + + it('renders different page sizes correctly - 12 creators', async () => { + const mockCreators = createMockCreators(12); + mockGetCourses.mockResolvedValue(mockCreators); + + renderWithProviders(); + + await waitFor(() => { + const cards = screen.getAllByTestId(/^creator-card-/); + // Default page size is 6, so first page should have 6 cards + expect(cards.length).toBeLessThanOrEqual(12); + expect(cards.length).toBeGreaterThan(0); + }); + }); + + it('renders no cards when API returns empty array', async () => { + mockGetCourses.mockResolvedValue([]); + + renderWithProviders(); + + await waitFor(() => { + const cards = screen.queryAllByTestId(/^creator-card-/); + expect(cards).toHaveLength(0); + }); + }); + + it('card count matches API response exactly - 8 creators with no hidden elements', async () => { + const mockCreators = createMockCreators(8); + mockGetCourses.mockResolvedValue(mockCreators); + + const { container } = renderWithProviders(); + + await waitFor(() => { + const visibleCards = screen.getAllByTestId(/^creator-card-/); + expect(visibleCards).toHaveLength(8); + + // Verify no hidden cards in the DOM + const allArticles = container.querySelectorAll( + 'article[data-testid^="creator-card-"]' + ); + const visibleArticles = Array.from(allArticles).filter( + el => el.checkVisibility && el.checkVisibility() + ); + expect(visibleArticles.length).toBeLessThanOrEqual(8); + }); + }); +}); diff --git a/src/pages/__tests__/LandingPage.debouncedSearch.integration.test.tsx b/src/pages/__tests__/LandingPage.debouncedSearch.integration.test.tsx new file mode 100644 index 00000000..d4beb98d --- /dev/null +++ b/src/pages/__tests__/LandingPage.debouncedSearch.integration.test.tsx @@ -0,0 +1,141 @@ +/** + * Integration test for the debounced creator search (#489). + * + * Strategy: render a minimal wrapper that uses useDebounce with the real hook + * (fake timers) and calls courseService.getCourses in a useEffect, identical + * to what LandingPage does. This avoids the URL-sync / price-refresh side + * effects that make call-count assertions brittle when testing the full page. + */ +import { act, fireEvent, render, screen } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { useEffect, useState } from 'react'; +import { useDebounce } from '@/hooks/useDebounce'; +import { courseService, type Course } from '@/services/course.service'; + +vi.mock('@/services/course.service', () => ({ + courseService: { getCourses: vi.fn() }, +})); + +const mockGetCourses = vi.mocked(courseService.getCourses); + +const creator: Course = { + id: '1', + title: 'Creator Alpha', + description: 'Digital artist', + price: 0.05, + priceStroops: 500_000, + creatorShareSupply: 100, + instructorId: 'creator-a', + category: 'Art', + level: 'BEGINNER', + isVerified: true, +}; + +// Minimal harness — mirrors the search → debounce → API pattern from LandingPage. +function DebouncedSearchHarness({ delay = 300 }: { delay?: number }) { + const [query, setQuery] = useState(''); + const debouncedQuery = useDebounce(query, delay); + + useEffect(() => { + const params = debouncedQuery.trim() + ? { search: debouncedQuery.trim() } + : undefined; + courseService.getCourses(params); + }, [debouncedQuery]); + + return ( + setQuery(e.target.value)} + /> + ); +} + +describe('Debounced search – integration (#489)', () => { + beforeEach(() => { + vi.useFakeTimers(); + mockGetCourses.mockReset(); + mockGetCourses.mockResolvedValue([creator]); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('fires no API calls during the typing burst (before the delay elapses)', () => { + render(); + + // The initial render triggers one call (debouncedQuery = ''). + act(() => { vi.advanceTimersByTime(300); }); + expect(mockGetCourses).toHaveBeenCalledTimes(1); + mockGetCourses.mockClear(); + + const input = screen.getByTestId('search'); + + // Type five characters, each 50 ms apart — all within the 300 ms window. + for (const value of ['a', 'ab', 'abc', 'abcd', 'abcde']) { + act(() => { + fireEvent.change(input, { target: { value } }); + vi.advanceTimersByTime(50); + }); + } + + // Debounce has NOT expired yet → no additional API calls. + expect(mockGetCourses).not.toHaveBeenCalled(); + }); + + it('fires exactly one API call after the debounce delay with the final value', () => { + render(); + + // Flush the initial render call. + act(() => { vi.advanceTimersByTime(300); }); + mockGetCourses.mockClear(); + + const input = screen.getByTestId('search'); + + // Rapid-fire five keystrokes within the 300 ms window. + for (const value of ['a', 'ab', 'abc', 'abcd', 'abcde']) { + act(() => { + fireEvent.change(input, { target: { value } }); + vi.advanceTimersByTime(50); + }); + } + + // Advance past the full debounce window for the last keystroke. + act(() => { vi.advanceTimersByTime(300); }); + + // Exactly one call, using the final typed value. + expect(mockGetCourses).toHaveBeenCalledTimes(1); + expect(mockGetCourses).toHaveBeenCalledWith({ search: 'abcde' }); + }); + + it('omits the search param when the input is cleared', () => { + render(); + + act(() => { vi.advanceTimersByTime(300); }); + mockGetCourses.mockClear(); + + const input = screen.getByTestId('search'); + + // Type a value, let it settle. + act(() => { + fireEvent.change(input, { target: { value: 'hello' } }); + }); + act(() => { vi.advanceTimersByTime(300); }); + expect(mockGetCourses).toHaveBeenLastCalledWith({ search: 'hello' }); + + mockGetCourses.mockClear(); + + // Clear the input. + act(() => { + fireEvent.change(input, { target: { value: '' } }); + }); + act(() => { vi.advanceTimersByTime(300); }); + + // Empty query → no search param (component passes undefined). + expect(mockGetCourses).toHaveBeenCalledTimes(1); + expect(mockGetCourses).toHaveBeenCalledWith(undefined); + }); +}); diff --git a/src/pages/__tests__/LandingPage.debouncedSearchClear.integration.test.tsx b/src/pages/__tests__/LandingPage.debouncedSearchClear.integration.test.tsx new file mode 100644 index 00000000..24ebbdd2 --- /dev/null +++ b/src/pages/__tests__/LandingPage.debouncedSearchClear.integration.test.tsx @@ -0,0 +1,218 @@ +/** + * Integration test for debounced search clearing (#519). + * + * Confirms that clearing the search input triggers a debounced refetch without + * a search param and restores the full unfiltered creator list. + */ +import type { ComponentProps, ReactNode } from 'react'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { MemoryRouter, useLocation } from 'react-router'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import LandingPage from '@/pages/LandingPage'; +import { + courseService, + type Course, + type GetCoursesParams, +} from '@/services/course.service'; + +vi.mock('@/services/course.service', () => ({ + courseService: { getCourses: vi.fn() }, +})); + +vi.mock('@/hooks/useNetworkMismatch', () => ({ + useNetworkMismatch: () => ({ + isMismatch: false, + expectedChainName: 'Stellar Testnet', + }), +})); + +vi.mock('@/hooks/useStaleData', () => ({ + useStaleData: () => ({ + stale: false, + ageMs: 0, + msUntilStale: 60_000, + revalidate: vi.fn(), + }), +})); + +vi.mock('@/components/common/StellarConnectionQualityBadge', async () => { + const React = await import('react'); + + return { + default: () => React.createElement('div', { role: 'status' }, 'RPC good'), + }; +}); + +vi.mock('@/components/common/CreatorCard', async () => { + const React = await import('react'); + + return { + default: ({ creator }: { creator: { title: string } }) => + React.createElement( + 'article', + { 'aria-label': `Creator ${creator.title}` }, + creator.title + ), + }; +}); + +vi.mock('framer-motion', async () => { + const React = await import('react'); + type MotionDivProps = ComponentProps<'div'> & { + layout?: boolean; + transition?: unknown; + }; + + return { + AnimatePresence: ({ children }: { children: ReactNode }) => + React.createElement(React.Fragment, null, children), + LayoutGroup: ({ children }: { children: ReactNode }) => + React.createElement(React.Fragment, null, children), + motion: { + div: ({ children, ...props }: MotionDivProps) => { + const { layout, transition, ...divProps } = props; + void layout; + void transition; + + return React.createElement('div', divProps, children); + }, + h1: ({ children, ...props }: ComponentProps<'h1'>) => + React.createElement('h1', props, children), + button: ({ children, ...props }: ComponentProps<'button'>) => + React.createElement('button', props, children), + }, + }; +}); + +const mockGetCourses = vi.mocked(courseService.getCourses); + +const creatorAlpha: Course = { + id: '1', + title: 'Creator Alpha', + description: 'Digital artist', + price: 0.5, + priceStroops: 5_000_000, + creatorShareSupply: 100, + instructorId: 'creator-alpha', + category: 'Art', + level: 'BEGINNER', + isVerified: true, +}; + +const creatorBeta: Course = { + id: '2', + title: 'Creator Beta', + description: 'Music producer', + price: 0.1, + priceStroops: 1_000_000, + creatorShareSupply: 50, + instructorId: 'creator-beta', + category: 'Music', + level: 'INTERMEDIATE', + isVerified: true, +}; + +const allCreators = [creatorAlpha, creatorBeta]; + +const mockMatchMedia = () => { + Object.defineProperty(window, 'matchMedia', { + writable: true, + value: vi.fn().mockImplementation((query: string) => ({ + matches: false, + media: query, + onchange: null, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + addListener: vi.fn(), + removeListener: vi.fn(), + dispatchEvent: vi.fn(), + })), + }); +}; + +const getCreatorTitles = () => + screen.getAllByRole('article').map(node => node.textContent); + +function RouteLocationTracker() { + const location = useLocation(); + return
{location.search}
; +} + +function makeQueryClient() { + return new QueryClient({ defaultOptions: { queries: { retry: false } } }); +} + +describe('LandingPage debounced search clear integration (#519)', () => { + beforeEach(() => { + mockMatchMedia(); + window.localStorage.clear(); + window.sessionStorage.clear(); + mockGetCourses.mockReset(); + mockGetCourses.mockImplementation(async (params?: GetCoursesParams) => { + if (params?.search) return [creatorBeta]; + return allCreators; + }); + }); + + it('re-fetches without a search param and restores the full creator list after the input is cleared', async () => { + render( + + + + + + ); + + await waitFor(() => expect(mockGetCourses).toHaveBeenCalledTimes(1)); + expect(mockGetCourses).toHaveBeenLastCalledWith(undefined); + await waitFor(() => + expect(getCreatorTitles()).toEqual(['Creator Alpha', 'Creator Beta']) + ); + + const input = screen.getByPlaceholderText( + /search creators by name or handle/i + ); + + fireEvent.change(input, { target: { value: 'Beta' } }); + + await waitFor(() => expect(mockGetCourses).toHaveBeenCalledTimes(2)); + expect(mockGetCourses).toHaveBeenLastCalledWith({ search: 'Beta' }); + const inputClear = await screen.findByPlaceholderText( + /search creators by name or handle/i + ); + fireEvent.change(inputClear, { target: { value: '' } }); + + await waitFor(() => expect(mockGetCourses).toHaveBeenCalledTimes(3)); + expect(mockGetCourses).toHaveBeenLastCalledWith(undefined); + await waitFor(() => + expect(getCreatorTitles()).toEqual(['Creator Alpha', 'Creator Beta']) + ); + }); + + it('removes search param from the URL and resets to page one after clearing', async () => { + render( + + + + + + + ); + + await waitFor(() => expect(mockGetCourses).toHaveBeenCalledTimes(1)); + expect(mockGetCourses).toHaveBeenLastCalledWith({ search: 'Beta' }); + + const input = screen.getByPlaceholderText( + /search creators by name or handle/i + ); + fireEvent.change(input, { target: { value: '' } }); + + await waitFor(() => expect(mockGetCourses).toHaveBeenCalledTimes(2)); + expect(mockGetCourses).toHaveBeenLastCalledWith(undefined); + + await waitFor(() => { + expect(screen.getByTestId('location-search')).toHaveTextContent(''); + }); + }); +}); diff --git a/src/pages/__tests__/LandingPage.emptyState.integration.test.tsx b/src/pages/__tests__/LandingPage.emptyState.integration.test.tsx new file mode 100644 index 00000000..5968ee66 --- /dev/null +++ b/src/pages/__tests__/LandingPage.emptyState.integration.test.tsx @@ -0,0 +1,177 @@ +import type { ComponentProps, ReactNode } from 'react'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { MemoryRouter } from 'react-router'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import LandingPage from '@/pages/LandingPage'; +import { courseService, type Course } from '@/services/course.service'; + +vi.mock('@/hooks/useWallet', () => ({ + useTradeMutation: () => ({ mutateAsync: vi.fn(), isPending: false }), + useWalletHoldings: () => ({ data: [] }), +})); + +vi.mock('@/services/course.service', () => ({ + courseService: { getCourses: vi.fn() }, +})); + +vi.mock('@/hooks/useNetworkMismatch', () => ({ + useNetworkMismatch: () => ({ + isMismatch: false, + expectedChainName: 'Stellar Testnet', + }), +})); + +vi.mock('@/hooks/useStaleData', () => ({ + useStaleData: () => ({ + stale: false, + ageMs: 0, + msUntilStale: 60_000, + revalidate: vi.fn(), + }), +})); + +vi.mock('@/components/common/StellarConnectionQualityBadge', async () => { + const React = await import('react'); + return { + default: () => React.createElement('div', { role: 'status' }, 'RPC good'), + }; +}); + +vi.mock('@/components/common/CreatorCard', async () => { + const React = await import('react'); + return { + default: ({ creator }: { creator: { title: string } }) => + React.createElement( + 'article', + { 'aria-label': `Creator ${creator.title}` }, + creator.title + ), + }; +}); + +vi.mock('framer-motion', async () => { + const React = await import('react'); + type MotionDivProps = ComponentProps<'div'> & { + layout?: boolean; + transition?: unknown; + }; + return { + AnimatePresence: ({ children }: { children: ReactNode }) => + React.createElement(React.Fragment, null, children), + LayoutGroup: ({ children }: { children: ReactNode }) => + React.createElement(React.Fragment, null, children), + motion: { + div: ({ children, ...props }: MotionDivProps) => { + const { layout, transition, ...divProps } = props; + void layout; + void transition; + return React.createElement('div', divProps, children); + }, + h1: ({ children, ...props }: ComponentProps<'h1'>) => + React.createElement('h1', props, children), + button: ({ children, ...props }: ComponentProps<'button'>) => + React.createElement('button', props, children), + }, + }; +}); + +const mockGetCourses = vi.mocked(courseService.getCourses); + +const creator: Course = { + id: 'creator-a', + title: 'Creator Alpha', + description: 'Digital artist', + price: 0.05, + priceStroops: 500_000, + creatorShareSupply: 100, + instructorId: 'creator-a', + category: 'Art', + level: 'BEGINNER', + isVerified: true, +}; + +const mockMatchMedia = () => { + Object.defineProperty(window, 'matchMedia', { + writable: true, + value: vi.fn().mockImplementation((query: string) => ({ + matches: false, + media: query, + onchange: null, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + addListener: vi.fn(), + removeListener: vi.fn(), + dispatchEvent: vi.fn(), + })), + }); +}; + +describe('LandingPage empty state integration (#452)', () => { + beforeEach(() => { + mockMatchMedia(); + window.localStorage.clear(); + window.sessionStorage.clear(); + mockGetCourses.mockReset(); + }); + + it('renders the empty state when API returns zero creators and a search term is entered', async () => { + mockGetCourses.mockResolvedValue([]); + render(); + await waitFor(() => expect(mockGetCourses).toHaveBeenCalledTimes(1)); + + fireEvent.change( + screen.getByPlaceholderText(/search creators by name or handle/i), + { target: { value: 'nobody' } } + ); + + expect( + await screen.findByRole('status', { name: /no creators found/i }) + ).toBeInTheDocument(); + }); + + it('renders the empty state when no creators match the search query', async () => { + mockGetCourses.mockResolvedValue([creator]); + render(); + await waitFor(() => expect(mockGetCourses).toHaveBeenCalledTimes(1)); + + fireEvent.change( + screen.getByPlaceholderText(/search creators by name or handle/i), + { target: { value: 'xyznotfound' } } + ); + + expect( + await screen.findByRole('status', { name: /no creators found/i }) + ).toBeInTheDocument(); + }); + + it('clear button resets the search input, hides the empty state, and re-fetches the full list', async () => { + mockGetCourses.mockResolvedValue([creator]); + render(); + await waitFor(() => expect(mockGetCourses).toHaveBeenCalledTimes(1)); + + // Type a query that yields no matches + fireEvent.change( + screen.getByPlaceholderText(/search creators by name or handle/i), + { target: { value: 'xyznotfound' } } + ); + await screen.findByRole('status', { name: /no creators found/i }); + + // Click the "Reset Search" button rendered by EmptyState + fireEvent.click(screen.getByRole('button', { name: /reset search/i })); + + // Empty state must disappear and the creator card must reappear + await waitFor(() => { + expect( + screen.queryByRole('status', { name: /no creators found/i }) + ).not.toBeInTheDocument(); + }); + expect( + screen.getByRole('article', { name: /creator alpha/i }) + ).toBeInTheDocument(); + + // Search input must be cleared + expect( + screen.getByPlaceholderText(/search creators by name or handle/i) + ).toHaveValue(''); + }); +}); diff --git a/src/pages/__tests__/LandingPage.holdings.test.tsx b/src/pages/__tests__/LandingPage.holdings.test.tsx new file mode 100644 index 00000000..af891e3d --- /dev/null +++ b/src/pages/__tests__/LandingPage.holdings.test.tsx @@ -0,0 +1,191 @@ +import type { ComponentProps, ReactNode } from 'react'; +import { render, screen } from '@testing-library/react'; +import { MemoryRouter } from 'react-router'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import LandingPage from '@/pages/LandingPage'; +import { courseService, type Course } from '@/services/course.service'; + +vi.mock('@/hooks/useWallet', () => ({ + useTradeMutation: () => ({ mutateAsync: vi.fn(), isPending: false }), + useWalletHoldings: () => ({ data: [] }), +})); + +vi.mock('@/services/course.service', () => ({ + courseService: { + getCourses: vi.fn(), + }, +})); + +vi.mock('@/hooks/useNetworkMismatch', () => ({ + useNetworkMismatch: () => ({ + isMismatch: false, + expectedChainName: 'Stellar Testnet', + }), +})); + +// Prevent the stale-data hook from triggering a background re-fetch on mount +// (creatorsFetchedAt starts as null → stale=true on first render, which fires +// onStale → re-fetch → resets isLoading=true → delays the portfolio display). +vi.mock('@/hooks/useStaleData', () => ({ + useStaleData: () => ({ + stale: false, + ageMs: 0, + msUntilStale: 60_000, + revalidate: vi.fn(), + }), +})); + +vi.mock('@/components/common/StellarConnectionQualityBadge', async () => { + const React = await import('react'); + + return { + default: () => React.createElement('div', { role: 'status' }, 'RPC good'), + }; +}); + +vi.mock('@/components/common/CreatorCard', async () => { + const React = await import('react'); + + return { + default: ({ creator }: { creator: { title: string } }) => + React.createElement( + 'article', + { 'aria-label': `Creator ${creator.title}` }, + creator.title + ), + }; +}); + +vi.mock('framer-motion', async () => { + const React = await import('react'); + type MotionDivProps = ComponentProps<'div'> & { + layout?: boolean; + transition?: unknown; + }; + + return { + AnimatePresence: ({ children }: { children: ReactNode }) => + React.createElement(React.Fragment, null, children), + LayoutGroup: ({ children }: { children: ReactNode }) => + React.createElement(React.Fragment, null, children), + motion: { + div: ({ children, ...props }: MotionDivProps) => { + const { layout, transition, ...divProps } = props; + void layout; + void transition; + + return React.createElement('div', divProps, children); + }, + h1: ({ children, ...props }: ComponentProps<'h1'>) => + React.createElement('h1', props, children), + button: ({ children, ...props }: ComponentProps<'button'>) => + React.createElement('button', props, children), + }, + }; +}); + +const mockMatchMedia = () => { + Object.defineProperty(window, 'matchMedia', { + writable: true, + value: vi.fn().mockImplementation((query: string) => ({ + matches: false, + media: query, + onchange: null, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + addListener: vi.fn(), + removeListener: vi.fn(), + dispatchEvent: vi.fn(), + })), + }); +}; + +const mockGetCourses = vi.mocked(courseService.getCourses); + +// Two seeded creators at known prices: +// Creator A (index 0 → featuredHoldings = 3): priceStroops 500_000 +// → 3 × 500_000 = 1_500_000 stroops = 0.15 XLM per position +// Creator B (index 1 → DEMO_HELD_KEY_QUANTITIES[1] = 2): priceStroops 1_200_000 +// → 2 × 1_200_000 = 2_400_000 stroops = 0.24 XLM per position +// Total: 3_900_000 stroops = 0.39 XLM +const seededCreators: Course[] = [ + { + id: 'creator-a', + title: 'Creator A', + description: 'Digital artist', + price: 0.05, + priceStroops: 500_000, + creatorShareSupply: 100, + instructorId: 'creator-a', + category: 'Art', + level: 'BEGINNER', + isVerified: true, + }, + { + id: 'creator-b', + title: 'Creator B', + description: 'Developer', + price: 0.12, + priceStroops: 1_200_000, + creatorShareSupply: 50, + instructorId: 'creator-b', + category: 'Tech', + level: 'ADVANCED', + isVerified: false, + }, +]; + +describe('LandingPage wallet holdings', () => { + beforeEach(() => { + mockMatchMedia(); + window.localStorage.clear(); + window.sessionStorage.clear(); + mockGetCourses.mockReset(); + }); + + it('displays total portfolio value equal to the sum of all held positions', async () => { + mockGetCourses.mockResolvedValue(seededCreators); + render(); + + // 3 × 500_000 + 2 × 1_200_000 = 3_900_000 stroops = 0.39 XLM + expect(await screen.findByText('0.39 XLM')).toBeInTheDocument(); + }); + + it('shows each holding card with the correct per-key price', async () => { + mockGetCourses.mockResolvedValue(seededCreators); + render(); + + // Wait for the portfolio total to load (past the 800 ms loading skeleton) + await screen.findByText('0.39 XLM'); + + // Holdings grid shows "N keys · price" text unique to each card + // Creator A: 3 × 500_000 stroops → 0.05 XLM/key + expect(screen.getByText('3 keys · 0.05 XLM')).toBeInTheDocument(); + // Creator B: 2 × 1_200_000 stroops → 0.12 XLM/key + expect(screen.getByText('2 keys · 0.12 XLM')).toBeInTheDocument(); + }); + + it('shows the correct total and helper text for a single held position', async () => { + const singleCreator = [ + { + id: 'solo', + title: 'Solo Creator', + description: 'Solo', + price: 0.1, + priceStroops: 1_000_000, + creatorShareSupply: 50, + instructorId: 'solo', + category: 'Art', + level: 'BEGINNER' as const, + }, + ]; + mockGetCourses.mockResolvedValue(singleCreator); + render(); + + // 3 (featuredHoldings) × 1_000_000 stroops = 3_000_000 stroops = 0.3 XLM + expect(await screen.findByText('0.3 XLM')).toBeInTheDocument(); + expect( + screen.getByText('Across 1 held creator position.') + ).toBeInTheDocument(); + }); +}); diff --git a/src/pages/__tests__/LandingPage.holdingsCount.integration.test.tsx b/src/pages/__tests__/LandingPage.holdingsCount.integration.test.tsx new file mode 100644 index 00000000..d2fa6a68 --- /dev/null +++ b/src/pages/__tests__/LandingPage.holdingsCount.integration.test.tsx @@ -0,0 +1,251 @@ +/** + * Integration test for portfolio holdings header entry count (#521). + * + * Confirms the holdings overview header count matches the number of held + * creator positions returned from the holdings response, including empty and + * refreshed responses. + */ +import type { ComponentProps, ReactNode } from 'react'; +import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'; +import { MemoryRouter } from 'react-router'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import LandingPage from '@/pages/LandingPage'; +import { courseService, type Course } from '@/services/course.service'; + +vi.mock('@/hooks/useWallet', () => ({ + useTradeMutation: () => ({ mutateAsync: vi.fn(), isPending: false }), + useWalletHoldings: () => ({ data: [] }), +})); + +vi.mock('@/services/course.service', () => ({ + courseService: { getCourses: vi.fn() }, +})); + +vi.mock('@/hooks/useNetworkMismatch', () => ({ + useNetworkMismatch: () => ({ + isMismatch: false, + expectedChainName: 'Stellar Testnet', + }), +})); + +vi.mock('@/hooks/useStaleData', () => ({ + useStaleData: () => ({ + stale: false, + ageMs: 0, + msUntilStale: 60_000, + revalidate: vi.fn(), + }), +})); + +vi.mock('@/components/common/StellarConnectionQualityBadge', async () => { + const React = await import('react'); + + return { + default: () => React.createElement('div', { role: 'status' }, 'RPC good'), + }; +}); + +vi.mock('@/components/common/CreatorCard', async () => { + const React = await import('react'); + + return { + default: ({ creator }: { creator: { title: string } }) => + React.createElement( + 'article', + { 'aria-label': `Creator ${creator.title}` }, + creator.title + ), + }; +}); + +vi.mock('framer-motion', async () => { + const React = await import('react'); + type MotionDivProps = ComponentProps<'div'> & { + layout?: boolean; + transition?: unknown; + }; + + return { + AnimatePresence: ({ children }: { children: ReactNode }) => + React.createElement(React.Fragment, null, children), + LayoutGroup: ({ children }: { children: ReactNode }) => + React.createElement(React.Fragment, null, children), + motion: { + div: ({ children, ...props }: MotionDivProps) => { + const { layout, transition, ...divProps } = props; + void layout; + void transition; + + return React.createElement('div', divProps, children); + }, + h1: ({ children, ...props }: ComponentProps<'h1'>) => + React.createElement('h1', props, children), + button: ({ children, ...props }: ComponentProps<'button'>) => + React.createElement('button', props, children), + }, + }; +}); + +const mockGetCourses = vi.mocked(courseService.getCourses); + +const threeHoldingsCreators: Course[] = [ + { + id: 'creator-a', + title: 'Creator A', + description: 'Digital artist', + price: 0.05, + priceStroops: 500_000, + creatorShareSupply: 100, + instructorId: 'creator-a', + category: 'Art', + level: 'BEGINNER', + isVerified: true, + }, + { + id: 'creator-b', + title: 'Creator B', + description: 'Developer', + price: 0.12, + priceStroops: 1_200_000, + creatorShareSupply: 50, + instructorId: 'creator-b', + category: 'Tech', + level: 'ADVANCED', + isVerified: false, + }, + { + id: 'creator-c', + title: 'Creator C', + description: 'Strategist', + price: 0.08, + priceStroops: 800_000, + creatorShareSupply: 75, + instructorId: 'creator-c', + category: 'Finance', + level: 'INTERMEDIATE', + isVerified: true, + }, +]; + +const singleHoldingCreator = [threeHoldingsCreators[0]]; + +const mockMatchMedia = () => { + Object.defineProperty(window, 'matchMedia', { + writable: true, + value: vi.fn().mockImplementation((query: string) => ({ + matches: false, + media: query, + onchange: null, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + addListener: vi.fn(), + removeListener: vi.fn(), + dispatchEvent: vi.fn(), + })), + }); +}; + +const getHoldingsOverviewSection = () => { + const heading = screen.getByRole('heading', { name: 'Total portfolio value' }); + const section = heading.closest('[aria-labelledby="holdings-overview-heading"]'); + expect(section).not.toBeNull(); + + return section as HTMLElement; +}; + +const getHoldingsHeaderEntryCount = () => + Number(screen.getByTestId('holdings-header-entry-count').textContent); + +const countHoldingsGridEntries = () => + within(getHoldingsOverviewSection()).queryAllByText(/\d+ keys ·/).length; + +const waitForHoldingsHeaderCount = async (count: number) => { + await waitFor(() => { + expect(getHoldingsHeaderEntryCount()).toBe(count); + }); +}; + +const triggerCreatorListRefresh = () => { + const shortcutEvent = new KeyboardEvent('keydown', { + key: 'r', + code: 'KeyR', + ctrlKey: true, + altKey: true, + bubbles: true, + cancelable: true, + }); + + fireEvent(window, shortcutEvent); +}; + +describe('LandingPage holdings header entry count integration (#521)', () => { + beforeEach(() => { + mockMatchMedia(); + window.localStorage.clear(); + window.sessionStorage.clear(); + mockGetCourses.mockReset(); + }); + + it('shows a header count of 3 when three holdings entries are returned', async () => { + mockGetCourses.mockResolvedValue(threeHoldingsCreators); + + render( + + + + ); + + await waitFor(() => expect(mockGetCourses).toHaveBeenCalledTimes(1)); + await waitForHoldingsHeaderCount(3); + + expect( + screen.getByText('Across 3 held creator positions.') + ).toBeInTheDocument(); + expect(countHoldingsGridEntries()).toBe(3); + }); + + it('shows a header count of 0 for an empty holdings response', async () => { + mockGetCourses.mockResolvedValue([]); + + render( + + + + ); + + await waitFor(() => expect(mockGetCourses).toHaveBeenCalledTimes(1)); + await waitForHoldingsHeaderCount(0); + + expect(screen.getByText('No held creator keys yet.')).toBeInTheDocument(); + expect( + within(getHoldingsOverviewSection()).getByText('0 XLM') + ).toBeInTheDocument(); + expect(countHoldingsGridEntries()).toBe(0); + }); + + it('updates the header count when holdings data is refreshed', async () => { + mockGetCourses + .mockResolvedValueOnce(threeHoldingsCreators) + .mockResolvedValueOnce(singleHoldingCreator); + + render( + + + + ); + + await waitFor(() => expect(mockGetCourses).toHaveBeenCalledTimes(1)); + await waitForHoldingsHeaderCount(3); + expect(countHoldingsGridEntries()).toBe(3); + + triggerCreatorListRefresh(); + + await waitFor(() => expect(mockGetCourses).toHaveBeenCalledTimes(2)); + await waitForHoldingsHeaderCount(1); + + expect( + screen.getByText('Across 1 held creator position.') + ).toBeInTheDocument(); + expect(countHoldingsGridEntries()).toBe(1); + }); +}); diff --git a/src/pages/__tests__/LandingPage.holdingsEmptyState.test.tsx b/src/pages/__tests__/LandingPage.holdingsEmptyState.test.tsx new file mode 100644 index 00000000..db48da28 --- /dev/null +++ b/src/pages/__tests__/LandingPage.holdingsEmptyState.test.tsx @@ -0,0 +1,182 @@ +/** + * Holdings empty-state UI (#539). + * + * When the holdings query settles with zero creator keys, the holdings + * overview must show an empty state (illustration + copy + CTA) instead of + * a blank grid. Loading must keep the skeleton so the empty state never + * flashes mid-fetch. + */ +import type { ComponentProps, ReactNode } from 'react'; +import { render, screen, waitFor, within } from '@testing-library/react'; +import { MemoryRouter } from 'react-router'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import LandingPage from '@/pages/LandingPage'; +import { courseService } from '@/services/course.service'; + +vi.mock('@/hooks/useWallet', () => ({ + useTradeMutation: () => ({ mutateAsync: vi.fn(), isPending: false }), + useWalletHoldings: () => ({ data: [] }), +})); + +vi.mock('@/services/course.service', () => ({ + courseService: { getCourses: vi.fn() }, +})); + +vi.mock('@/hooks/useNetworkMismatch', () => ({ + useNetworkMismatch: () => ({ + isMismatch: false, + expectedChainName: 'Stellar Testnet', + }), +})); + +vi.mock('@/hooks/useStaleData', () => ({ + useStaleData: () => ({ + stale: false, + ageMs: 0, + msUntilStale: 60_000, + revalidate: vi.fn(), + }), +})); + +vi.mock('@/components/common/StellarConnectionQualityBadge', async () => { + const React = await import('react'); + + return { + default: () => React.createElement('div', { role: 'status' }, 'RPC good'), + }; +}); + +vi.mock('@/components/common/CreatorCard', async () => { + const React = await import('react'); + + return { + default: ({ creator }: { creator: { title: string } }) => + React.createElement( + 'article', + { 'aria-label': `Creator ${creator.title}` }, + creator.title + ), + }; +}); + +vi.mock('framer-motion', async () => { + const React = await import('react'); + type MotionDivProps = ComponentProps<'div'> & { + layout?: boolean; + transition?: unknown; + }; + + return { + AnimatePresence: ({ children }: { children: ReactNode }) => + React.createElement(React.Fragment, null, children), + LayoutGroup: ({ children }: { children: ReactNode }) => + React.createElement(React.Fragment, null, children), + motion: { + div: ({ children, ...props }: MotionDivProps) => { + const { layout, transition, ...divProps } = props; + void layout; + void transition; + + return React.createElement('div', divProps, children); + }, + h1: ({ children, ...props }: ComponentProps<'h1'>) => + React.createElement('h1', props, children), + button: ({ children, ...props }: ComponentProps<'button'>) => + React.createElement('button', props, children), + }, + }; +}); + +const mockGetCourses = vi.mocked(courseService.getCourses); + +const mockMatchMedia = () => { + Object.defineProperty(window, 'matchMedia', { + writable: true, + value: vi.fn().mockImplementation((query: string) => ({ + matches: false, + media: query, + onchange: null, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + addListener: vi.fn(), + dispatchEvent: vi.fn(), + })), + }); +}; + +const getHoldingsOverviewSection = () => { + const heading = screen.getByRole('heading', { + name: 'Total portfolio value', + }); + const section = heading.closest( + '[aria-labelledby="holdings-overview-heading"]' + ); + expect(section).not.toBeNull(); + + return section as HTMLElement; +}; + +describe('LandingPage holdings empty state (#539)', () => { + beforeEach(() => { + mockMatchMedia(); + window.localStorage.clear(); + window.sessionStorage.clear(); + mockGetCourses.mockReset(); + }); + + it('shows empty state with CTA after holdings query settles empty', async () => { + mockGetCourses.mockResolvedValue([]); + + render( + + + + ); + + await waitFor(() => expect(mockGetCourses).toHaveBeenCalled()); + + const empty = await screen.findByTestId('holdings-empty-state'); + expect(empty).toBeInTheDocument(); + expect( + within(getHoldingsOverviewSection()).getByRole('heading', { + name: 'No creator keys yet', + }) + ).toBeInTheDocument(); + expect( + within(getHoldingsOverviewSection()).getByRole('link', { + name: 'Browse creators', + }) + ).toHaveAttribute('href', '/creators'); + // no holding cards + expect( + within(getHoldingsOverviewSection()).queryAllByText(/\d+ keys ·/) + .length + ).toBe(0); + }); + + it('keeps skeleton during loading and does not flash empty state early', async () => { + let resolveCourses!: (value: never[]) => void; + mockGetCourses.mockImplementation( + () => + new Promise(resolve => { + resolveCourses = resolve; + }) + ); + + render( + + + + ); + + // While loading: empty state must not be present + expect( + screen.queryByTestId('holdings-empty-state') + ).not.toBeInTheDocument(); + + resolveCourses([]); + expect( + await screen.findByTestId('holdings-empty-state') + ).toBeInTheDocument(); + }); +}); diff --git a/src/pages/__tests__/LandingPage.holdingsEmptyStateSkeleton.integration.test.tsx b/src/pages/__tests__/LandingPage.holdingsEmptyStateSkeleton.integration.test.tsx new file mode 100644 index 00000000..ce0d40c3 --- /dev/null +++ b/src/pages/__tests__/LandingPage.holdingsEmptyStateSkeleton.integration.test.tsx @@ -0,0 +1,215 @@ +/** + * Holdings empty-state loading skeleton coverage (#646). + * + * The existing #539 suite (LandingPage.holdingsEmptyState.test.tsx) already + * proves the empty state doesn't flash mid-fetch and appears once the + * holdings query settles with zero results. This suite adds the one + * assertion #646 asks for that #539 doesn't cover: that skeleton + * placeholders (CreatorHoldingsListSkeleton) are visible during the loading + * phase itself, not just "empty state absent". + */ +import type { ComponentProps, ReactNode } from 'react'; +import { render, screen, waitFor } from '@testing-library/react'; +import { MemoryRouter } from 'react-router'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import LandingPage from '@/pages/LandingPage'; +import { courseService } from '@/services/course.service'; + +vi.mock('@/hooks/useWallet', () => ({ + useTradeMutation: () => ({ mutateAsync: vi.fn(), isPending: false }), + useWalletHoldings: () => ({ data: [] }), +})); + +vi.mock('@/services/course.service', () => ({ + courseService: { getCourses: vi.fn() }, +})); + +vi.mock('@/hooks/useNetworkMismatch', () => ({ + useNetworkMismatch: () => ({ + isMismatch: false, + expectedChainName: 'Stellar Testnet', + }), +})); + +vi.mock('@/hooks/useStaleData', () => ({ + useStaleData: () => ({ + stale: false, + ageMs: 0, + msUntilStale: 60_000, + revalidate: vi.fn(), + }), +})); + +vi.mock('@/components/common/StellarConnectionQualityBadge', async () => { + const React = await import('react'); + + return { + default: () => React.createElement('div', { role: 'status' }, 'RPC good'), + }; +}); + +vi.mock('@/components/common/CreatorCard', async () => { + const React = await import('react'); + + return { + default: ({ creator }: { creator: { title: string } }) => + React.createElement( + 'article', + { 'aria-label': `Creator ${creator.title}` }, + creator.title + ), + }; +}); + +vi.mock('framer-motion', async () => { + const React = await import('react'); + type MotionDivProps = ComponentProps<'div'> & { + layout?: boolean; + transition?: unknown; + }; + + return { + AnimatePresence: ({ children }: { children: ReactNode }) => + React.createElement(React.Fragment, null, children), + LayoutGroup: ({ children }: { children: ReactNode }) => + React.createElement(React.Fragment, null, children), + motion: { + div: ({ children, ...props }: MotionDivProps) => { + const { layout, transition, ...divProps } = props; + void layout; + void transition; + + return React.createElement('div', divProps, children); + }, + h1: ({ children, ...props }: ComponentProps<'h1'>) => + React.createElement('h1', props, children), + button: ({ children, ...props }: ComponentProps<'button'>) => + React.createElement('button', props, children), + }, + }; +}); + +const mockGetCourses = vi.mocked(courseService.getCourses); + +// Newer Node versions expose a global WebStorage `localStorage` that +// shadows jsdom's and has no working methods; install a spec-compliant +// in-memory stub so this suite behaves identically on every Node version +// (see LandingPage.sellFlow.integration.test.tsx, #644). +const installStorageStub = (property: 'localStorage' | 'sessionStorage') => { + const store = new Map(); + Object.defineProperty(window, property, { + configurable: true, + writable: true, + value: { + getItem: (key: string) => store.get(String(key)) ?? null, + setItem: (key: string, value: string) => { + store.set(String(key), String(value)); + }, + removeItem: (key: string) => { + store.delete(String(key)); + }, + clear: () => store.clear(), + key: (index: number) => Array.from(store.keys())[index] ?? null, + get length() { + return store.size; + }, + }, + }); +}; + +const mockMatchMedia = () => { + Object.defineProperty(window, 'matchMedia', { + writable: true, + value: vi.fn().mockImplementation((query: string) => ({ + matches: false, + media: query, + onchange: null, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + addListener: vi.fn(), + removeListener: vi.fn(), + dispatchEvent: vi.fn(), + })), + }); +}; + +const getHoldingsOverviewSection = () => { + const heading = screen.getByRole('heading', { + name: 'Total portfolio value', + }); + const section = heading.closest( + '[aria-labelledby="holdings-overview-heading"]' + ); + expect(section).not.toBeNull(); + + return section as HTMLElement; +}; + +describe('LandingPage holdings empty state loading skeleton (#646)', () => { + beforeEach(() => { + mockMatchMedia(); + installStorageStub('localStorage'); + installStorageStub('sessionStorage'); + mockGetCourses.mockReset(); + }); + + it('shows skeleton placeholders while loading, not the empty state', async () => { + let resolveCourses!: (value: never[]) => void; + mockGetCourses.mockImplementation( + () => + new Promise(resolve => { + resolveCourses = resolve; + }) + ); + + render( + + + + ); + + // While loading: empty state absent, skeleton placeholders present + expect(screen.queryByTestId('holdings-empty-state')).not.toBeInTheDocument(); + + const section = getHoldingsOverviewSection(); + const skeletonShimmer = section.querySelectorAll('.skeleton-shimmer'); + expect(skeletonShimmer.length).toBeGreaterThan(0); + + resolveCourses([]); + + // After resolving empty: empty state appears, skeleton is gone + expect(await screen.findByTestId('holdings-empty-state')).toBeInTheDocument(); + await waitFor(() => { + expect( + getHoldingsOverviewSection().querySelectorAll('.skeleton-shimmer') + ).toHaveLength(0); + }); + }); + + it('does not show the empty state when the query resolves with results', async () => { + mockGetCourses.mockResolvedValue([ + { + id: '1', + title: 'Alex Rivers', + description: 'Digital Artist', + price: 0.05, + priceStroops: 500_000, + creatorShareSupply: 120, + instructorId: '1', + category: 'Art', + level: 'BEGINNER', + isVerified: true, + }, + ]); + + render( + + + + ); + + await waitFor(() => expect(mockGetCourses).toHaveBeenCalled()); + + expect(screen.queryByTestId('holdings-empty-state')).not.toBeInTheDocument(); + }); +}); diff --git a/src/pages/__tests__/LandingPage.holdingsGrandTotal.integration.test.tsx b/src/pages/__tests__/LandingPage.holdingsGrandTotal.integration.test.tsx new file mode 100644 index 00000000..a4bdf700 --- /dev/null +++ b/src/pages/__tests__/LandingPage.holdingsGrandTotal.integration.test.tsx @@ -0,0 +1,278 @@ +import type { ComponentProps, ReactNode } from 'react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { MemoryRouter } from 'react-router'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import LandingPage from '@/pages/LandingPage'; +import { courseService, type Course } from '@/services/course.service'; +import type { HeldKeyPosition } from '@/utils/portfolioValue.utils'; + +const holdingsStore = vi.hoisted(() => { + let holdings: HeldKeyPosition[] = []; + const listeners = new Set<() => void>(); + + return { + get: () => holdings, + set: (next: HeldKeyPosition[]) => { + holdings = next; + listeners.forEach(listener => listener()); + }, + subscribe: (listener: () => void) => { + listeners.add(listener); + return () => listeners.delete(listener); + }, + }; +}); + +vi.mock('@/hooks/useWallet', async () => { + const React = await import('react'); + + return { + useWalletHoldings: () => ({ + data: React.useSyncExternalStore( + holdingsStore.subscribe, + holdingsStore.get, + holdingsStore.get + ), + }), + useTradeMutation: () => ({ + isPending: false, + mutateAsync: async ({ + creatorId, + amount, + priceStroops, + price, + }: { + creatorId: string; + amount: number; + priceStroops: number | null | undefined; + price: number | null | undefined; + }) => { + const current = holdingsStore.get(); + const existing = current.find(entry => entry.creatorId === creatorId); + + if (existing) { + holdingsStore.set( + current.map(entry => + entry.creatorId === creatorId + ? { + ...entry, + quantity: (entry.quantity ?? 0) + amount, + priceStroops, + price, + pending: false, + } + : entry + ) + ); + } else { + holdingsStore.set([ + ...current, + { + creatorId, + quantity: amount, + priceStroops, + price, + pending: false, + }, + ]); + } + + return { success: true as const }; + }, + }), + }; +}); + +vi.mock('@/services/course.service', () => ({ + courseService: { getCourses: vi.fn() }, +})); + +vi.mock('@/utils/toast.util', () => ({ + default: { + message: vi.fn(), + success: vi.fn(), + error: vi.fn(), + loading: vi.fn(), + transactionSuccess: vi.fn(), + }, +})); + +vi.mock('@/hooks/useNetworkMismatch', () => ({ + useNetworkMismatch: () => ({ + isMismatch: false, + expectedChainName: 'Stellar Testnet', + }), +})); + +vi.mock('@/hooks/useStaleData', () => ({ + useStaleData: () => ({ + stale: false, + ageMs: 0, + msUntilStale: 60_000, + revalidate: vi.fn(), + }), +})); + +vi.mock('@/components/common/StellarConnectionQualityBadge', async () => { + const React = await import('react'); + + return { + default: () => React.createElement('div', { role: 'status' }, 'RPC good'), + }; +}); + +vi.mock('@/components/common/CreatorCard', async () => { + const React = await import('react'); + + return { + default: ({ creator }: { creator: { title: string } }) => + React.createElement( + 'article', + { 'aria-label': `Creator ${creator.title}` }, + creator.title + ), + }; +}); + +vi.mock('@/components/common/FeaturedCreatorAudienceChip', async () => { + const React = await import('react'); + + return { + FeaturedCreatorAudienceChip: () => + React.createElement('div', { 'data-testid': 'mock-audience-chip' }), + }; +}); + +vi.mock('framer-motion', async () => { + const React = await import('react'); + type MotionDivProps = ComponentProps<'div'> & { + layout?: boolean; + transition?: unknown; + }; + + return { + AnimatePresence: ({ children }: { children: ReactNode }) => + React.createElement(React.Fragment, null, children), + LayoutGroup: ({ children }: { children: ReactNode }) => + React.createElement(React.Fragment, null, children), + motion: { + div: ({ children, ...props }: MotionDivProps) => { + const { layout, transition, ...divProps } = props; + void layout; + void transition; + + return React.createElement('div', divProps, children); + }, + h1: ({ children, ...props }: ComponentProps<'h1'>) => + React.createElement('h1', props, children), + button: ({ children, ...props }: ComponentProps<'button'>) => + React.createElement('button', props, children), + }, + }; +}); + +const mockGetCourses = vi.mocked(courseService.getCourses); + +const creators: Course[] = [ + { + id: '1', + title: 'Featured Creator', + description: 'Featured creator', + price: 100, + priceStroops: 1_000_000_000, + creatorShareSupply: 100, + instructorId: 'featured', + category: 'Art', + level: 'BEGINNER', + isVerified: true, + }, + { + id: '2', + title: 'Existing Creator', + description: 'Existing creator', + price: 500, + priceStroops: 5_000_000_000, + creatorShareSupply: 50, + instructorId: 'existing', + category: 'Tech', + level: 'ADVANCED', + isVerified: true, + }, +]; + +const mockMatchMedia = () => { + Object.defineProperty(window, 'matchMedia', { + writable: true, + value: vi.fn().mockImplementation((query: string) => ({ + matches: false, + media: query, + onchange: null, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + addListener: vi.fn(), + removeListener: vi.fn(), + dispatchEvent: vi.fn(), + })), + }); +}; + +describe('LandingPage holdings grand total after buy confirmation (#660)', () => { + beforeEach(() => { + mockMatchMedia(); + window.localStorage.clear(); + window.sessionStorage.clear(); + mockGetCourses.mockReset(); + mockGetCourses.mockResolvedValue(creators); + holdingsStore.set([ + { + creatorId: '1', + quantity: 0, + priceStroops: 1_000_000_000, + price: 100, + pending: false, + }, + { + creatorId: '2', + quantity: 1, + priceStroops: 5_000_000_000, + price: 500, + pending: false, + }, + ]); + }); + + afterEach(() => { + cleanup(); + }); + + it('updates the holdings grand total immediately after a buy confirmation and shows the new holding entry', async () => { + render( + + + + + + ); + + expect(await screen.findByText('500 XLM')).toBeInTheDocument(); + expect(screen.getByText('1 keys · 500 XLM')).toBeInTheDocument(); + expect(screen.queryByText('2 keys · 100 XLM')).not.toBeInTheDocument(); + + const [buyButton] = screen.getAllByRole('button', { name: 'Buy' }); + fireEvent.click(buyButton); + + fireEvent.change(await screen.findByTestId('trade-dialog-amount'), { + target: { value: '2' }, + }); + fireEvent.click(screen.getByTestId('trade-dialog-confirm')); + + await waitFor(() => { + expect(screen.getByText('700 XLM')).toBeInTheDocument(); + }); + expect(screen.getByText('2 keys · 100 XLM')).toBeInTheDocument(); + expect(screen.getByText('1 keys · 500 XLM')).toBeInTheDocument(); + }); +}); diff --git a/src/pages/__tests__/LandingPage.holdingsGrandTotalSum.integration.test.tsx b/src/pages/__tests__/LandingPage.holdingsGrandTotalSum.integration.test.tsx new file mode 100644 index 00000000..21222175 --- /dev/null +++ b/src/pages/__tests__/LandingPage.holdingsGrandTotalSum.integration.test.tsx @@ -0,0 +1,346 @@ +/** + * Integration test for holdings grand total summing across multiple held creators (#598). + * + * The holdings overview should display a grand total XLM value that equals the + * sum of all individual creator holding values (quantity × priceStroops each). + * This test seeds three holdings with known values via the react-query cache, + * asserts the grand total matches the expected sum, and verifies the total + * re-calculates when cache data changes. + * + * Acceptance Criteria: + * - Grand total displayed on holdings page + * - Grand total equals the sum of all individual total_value fields + * - Grand total updates when holdings cache changes + * - Grand total formatted using XLM display format + */ +import type { ComponentProps, ReactNode } from 'react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { cleanup, render, screen, waitFor } from '@testing-library/react'; +import { MemoryRouter } from 'react-router'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import LandingPage from '@/pages/LandingPage'; +import { courseService, type Course } from '@/services/course.service'; +import type { HeldKeyPosition } from '@/utils/portfolioValue.utils'; + +const holdingsStore = vi.hoisted(() => { + let holdings: HeldKeyPosition[] = []; + const listeners = new Set<() => void>(); + + return { + get: () => holdings, + set: (next: HeldKeyPosition[]) => { + holdings = next; + listeners.forEach(listener => listener()); + }, + subscribe: (listener: () => void) => { + listeners.add(listener); + return () => listeners.delete(listener); + }, + }; +}); + +vi.mock('@/hooks/useWallet', async () => { + const React = await import('react'); + + return { + useWalletHoldings: () => ({ + data: React.useSyncExternalStore( + holdingsStore.subscribe, + holdingsStore.get, + holdingsStore.get + ), + }), + useTradeMutation: () => ({ + isPending: false, + mutateAsync: vi.fn(), + }), + }; +}); + +vi.mock('@/services/course.service', () => ({ + courseService: { getCourses: vi.fn() }, +})); + +vi.mock('@/utils/toast.util', () => ({ + default: { + message: vi.fn(), + success: vi.fn(), + error: vi.fn(), + loading: vi.fn(), + transactionSuccess: vi.fn(), + }, +})); + +vi.mock('@/hooks/useNetworkMismatch', () => ({ + useNetworkMismatch: () => ({ + isMismatch: false, + expectedChainName: 'Stellar Testnet', + }), +})); + +vi.mock('@/hooks/useStaleData', () => ({ + useStaleData: () => ({ + stale: false, + ageMs: 0, + msUntilStale: 60_000, + revalidate: vi.fn(), + }), +})); + +vi.mock('@/components/common/StellarConnectionQualityBadge', async () => { + const React = await import('react'); + + return { + default: () => React.createElement('div', { role: 'status' }, 'RPC good'), + }; +}); + +vi.mock('@/components/common/CreatorCard', async () => { + const React = await import('react'); + + return { + default: ({ creator }: { creator: { title: string } }) => + React.createElement( + 'article', + { 'aria-label': `Creator ${creator.title}` }, + creator.title + ), + }; +}); + +vi.mock('framer-motion', async () => { + const React = await import('react'); + type MotionDivProps = ComponentProps<'div'> & { + layout?: boolean; + transition?: unknown; + }; + + return { + AnimatePresence: ({ children }: { children: ReactNode }) => + React.createElement(React.Fragment, null, children), + LayoutGroup: ({ children }: { children: ReactNode }) => + React.createElement(React.Fragment, null, children), + motion: { + div: ({ children, ...props }: MotionDivProps) => { + const { layout, transition, ...divProps } = props; + void layout; + void transition; + + return React.createElement('div', divProps, children); + }, + h1: ({ children, ...props }: ComponentProps<'h1'>) => + React.createElement('h1', props, children), + button: ({ children, ...props }: ComponentProps<'button'>) => + React.createElement('button', props, children), + }, + }; +}); + +const mockGetCourses = vi.mocked(courseService.getCourses); + +// Three creators at prices that sum to 400 XLM: +// Creator A: priceStroops 1_000_000_000 (100 XLM/key) with quantity 1 +// Creator B: priceStroops 2_500_000_000 (250 XLM/key) with quantity 1 +// Creator C: priceStroops 500_000_000 ( 50 XLM/key) with quantity 1 +// Total: 1_000_000_000 + 2_500_000_000 + 500_000_000 = 4_000_000_000 stroops = 400 XLM +const threeHoldingsCreators: Course[] = [ + { + id: 'creator-a', + title: 'Creator A', + description: 'Digital artist', + price: 100, + priceStroops: 1_000_000_000, + creatorShareSupply: 100, + instructorId: 'creator-a', + category: 'Art', + level: 'BEGINNER', + isVerified: true, + }, + { + id: 'creator-b', + title: 'Creator B', + description: 'Developer', + price: 250, + priceStroops: 2_500_000_000, + creatorShareSupply: 50, + instructorId: 'creator-b', + category: 'Tech', + level: 'ADVANCED', + isVerified: true, + }, + { + id: 'creator-c', + title: 'Creator C', + description: 'Strategist', + price: 50, + priceStroops: 500_000_000, + creatorShareSupply: 75, + instructorId: 'creator-c', + category: 'Finance', + level: 'INTERMEDIATE', + isVerified: false, + }, +]; + +const holdingsSeed: HeldKeyPosition[] = [ + { + creatorId: 'creator-a', + quantity: 1, + priceStroops: 1_000_000_000, + price: 100, + pending: false, + }, + { + creatorId: 'creator-b', + quantity: 1, + priceStroops: 2_500_000_000, + price: 250, + pending: false, + }, + { + creatorId: 'creator-c', + quantity: 1, + priceStroops: 500_000_000, + price: 50, + pending: false, + }, +]; + +const mockMatchMedia = () => { + Object.defineProperty(window, 'matchMedia', { + writable: true, + value: vi.fn().mockImplementation((query: string) => ({ + matches: false, + media: query, + onchange: null, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + addListener: vi.fn(), + removeListener: vi.fn(), + dispatchEvent: vi.fn(), + })), + }); +}; + +describe('LandingPage holdings grand total sum (#598)', () => { + beforeEach(() => { + mockMatchMedia(); + window.localStorage.clear(); + window.sessionStorage.clear(); + mockGetCourses.mockReset(); + mockGetCourses.mockResolvedValue(threeHoldingsCreators); + holdingsStore.set(holdingsSeed); + }); + + afterEach(() => { + cleanup(); + }); + + it('displays grand total equal to the sum of all held positions', async () => { + render( + + + + + + ); + + // Grand total: 1 × 1_000_000_000 + 1 × 2_500_000_000 + 1 × 500_000_000 + // = 4_000_000_000 stroops = 400 XLM + expect(await screen.findByText('400 XLM')).toBeInTheDocument(); + expect( + screen.getByText('Across 3 held creator positions.') + ).toBeInTheDocument(); + }); + + it('recalculates grand total when a holding quantity changes in the cache', async () => { + render( + + + + + + ); + + // Assert initial total of 400 XLM + expect(await screen.findByText('400 XLM')).toBeInTheDocument(); + + // Update Creator C's quantity from 1 to 2: + // New total: 100 XLM + 250 XLM + 2 × 50 XLM = 450 XLM + holdingsStore.set( + holdingsSeed.map(h => + h.creatorId === 'creator-c' ? { ...h, quantity: 2 } : h + ) + ); + + await waitFor(() => { + expect(screen.getByText('450 XLM')).toBeInTheDocument(); + }); + expect( + screen.getByText('Across 3 held creator positions.') + ).toBeInTheDocument(); + }); + + it('recalculates grand total when a holding quantity decreases in the cache', async () => { + render( + + + + + + ); + + // Assert initial total of 400 XLM + expect(await screen.findByText('400 XLM')).toBeInTheDocument(); + expect( + screen.getByText('Across 3 held creator positions.') + ).toBeInTheDocument(); + + // Decrease Creator B's quantity from 1 to 0: + // New total: 100 XLM + 0 XLM + 50 XLM = 150 XLM + holdingsStore.set( + holdingsSeed.map(h => + h.creatorId === 'creator-b' ? { ...h, quantity: 0 } : h + ) + ); + + await waitFor(() => { + expect(screen.getByText('150 XLM')).toBeInTheDocument(); + }); + expect( + screen.getByText('Across 2 held creator positions.') + ).toBeInTheDocument(); + }); + + it('shows empty state when no held positions have positive quantity', async () => { + holdingsStore.set( + holdingsSeed.map(h => ({ ...h, quantity: 0 })) + ); + + render( + + + + + + ); + + expect(await screen.findByText('No held creator keys yet.')).toBeInTheDocument(); + }); +}); diff --git a/src/pages/__tests__/LandingPage.holdingsLoadingSkeleton.integration.test.tsx b/src/pages/__tests__/LandingPage.holdingsLoadingSkeleton.integration.test.tsx new file mode 100644 index 00000000..090106d6 --- /dev/null +++ b/src/pages/__tests__/LandingPage.holdingsLoadingSkeleton.integration.test.tsx @@ -0,0 +1,227 @@ +/** + * Integration test for the holdings loading skeleton (#627). + * + * Confirms the holdings overview shows skeleton placeholders while the holdings + * query is in flight, that the placeholders are replaced by real holding entries + * once the response resolves, and that no skeleton remains after data loads. + */ +import type { ComponentProps, ReactNode } from 'react'; +import { render, screen, waitFor, within } from '@testing-library/react'; +import { MemoryRouter } from 'react-router'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import LandingPage from '@/pages/LandingPage'; +import { courseService, type Course } from '@/services/course.service'; + +vi.mock('@/hooks/useWallet', () => ({ + useTradeMutation: () => ({ mutateAsync: vi.fn(), isPending: false }), + useWalletHoldings: () => ({ data: [] }), +})); + +vi.mock('@/services/course.service', () => ({ + courseService: { getCourses: vi.fn() }, +})); + +vi.mock('@/hooks/useNetworkMismatch', () => ({ + useNetworkMismatch: () => ({ + isMismatch: false, + expectedChainName: 'Stellar Testnet', + }), +})); + +// Keep the stale-data hook quiet so it does not trigger a background re-fetch +// that would flip isLoading back to true mid-assertion. +vi.mock('@/hooks/useStaleData', () => ({ + useStaleData: () => ({ + stale: false, + ageMs: 0, + msUntilStale: 60_000, + revalidate: vi.fn(), + }), +})); + +vi.mock('@/components/common/StellarConnectionQualityBadge', async () => { + const React = await import('react'); + + return { + default: () => React.createElement('div', { role: 'status' }, 'RPC good'), + }; +}); + +vi.mock('@/components/common/CreatorCard', async () => { + const React = await import('react'); + + return { + default: ({ creator }: { creator: { title: string } }) => + React.createElement( + 'article', + { 'aria-label': `Creator ${creator.title}` }, + creator.title + ), + }; +}); + +vi.mock('framer-motion', async () => { + const React = await import('react'); + type MotionDivProps = ComponentProps<'div'> & { + layout?: boolean; + transition?: unknown; + }; + + return { + AnimatePresence: ({ children }: { children: ReactNode }) => + React.createElement(React.Fragment, null, children), + LayoutGroup: ({ children }: { children: ReactNode }) => + React.createElement(React.Fragment, null, children), + motion: { + div: ({ children, ...props }: MotionDivProps) => { + const { layout, transition, ...divProps } = props; + void layout; + void transition; + + return React.createElement('div', divProps, children); + }, + h1: ({ children, ...props }: ComponentProps<'h1'>) => + React.createElement('h1', props, children), + button: ({ children, ...props }: ComponentProps<'button'>) => + React.createElement('button', props, children), + }, + }; +}); + +const mockGetCourses = vi.mocked(courseService.getCourses); + +const seededCreators: Course[] = [ + { + id: 'creator-a', + title: 'Creator A', + description: 'Digital artist', + price: 0.05, + priceStroops: 500_000, + creatorShareSupply: 100, + instructorId: 'creator-a', + category: 'Art', + level: 'BEGINNER', + isVerified: true, + }, + { + id: 'creator-b', + title: 'Creator B', + description: 'Developer', + price: 0.12, + priceStroops: 1_200_000, + creatorShareSupply: 50, + instructorId: 'creator-b', + category: 'Tech', + level: 'ADVANCED', + isVerified: false, + }, +]; + +const mockMatchMedia = () => { + Object.defineProperty(window, 'matchMedia', { + writable: true, + value: vi.fn().mockImplementation((query: string) => ({ + matches: false, + media: query, + onchange: null, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + addListener: vi.fn(), + removeListener: vi.fn(), + dispatchEvent: vi.fn(), + })), + }); +}; + +// A promise whose resolution is controlled by the test, so we can render while +// the holdings query is still in flight and then let it settle on demand. +const createDeferred = () => { + let resolve!: (value: T) => void; + const promise = new Promise(res => { + resolve = res; + }); + + return { promise, resolve }; +}; + +const getHoldingsOverviewSection = () => { + const heading = screen.getByRole('heading', { name: 'Total portfolio value' }); + const section = heading.closest( + '[aria-labelledby="holdings-overview-heading"]' + ); + expect(section).not.toBeNull(); + + return section as HTMLElement; +}; + +describe('LandingPage holdings loading skeleton integration (#627)', () => { + beforeEach(() => { + mockMatchMedia(); + window.localStorage.clear(); + window.sessionStorage.clear(); + mockGetCourses.mockReset(); + }); + + it('shows skeleton placeholders while the holdings query is in flight', async () => { + const deferred = createDeferred(); + mockGetCourses.mockReturnValue(deferred.promise); + + render( + + + + ); + + const holdingsSection = getHoldingsOverviewSection(); + + // While the query is pending, the skeleton is visible with its default + // three placeholder items and no real holding entries. + const skeleton = await within(holdingsSection).findByTestId( + 'holdings-list-skeleton' + ); + expect(skeleton).toBeInTheDocument(); + expect( + within(skeleton).getAllByTestId('holdings-skeleton-item') + ).toHaveLength(3); + expect( + within(holdingsSection).queryByText(/\d+ keys ·/) + ).not.toBeInTheDocument(); + + // Let the query resolve so the test does not leak a pending promise. + deferred.resolve(seededCreators); + const entryTexts = await within(holdingsSection).findAllByText(/\d+ keys ·/); + expect(entryTexts).toHaveLength(2); + }); + + it('replaces the skeleton with holding entries once data arrives', async () => { + const deferred = createDeferred(); + mockGetCourses.mockReturnValue(deferred.promise); + + render( + + + + ); + + const holdingsSection = getHoldingsOverviewSection(); + await within(holdingsSection).findByTestId('holdings-list-skeleton'); + + deferred.resolve(seededCreators); + + // Real holding entries appear once the response resolves. + await within(holdingsSection).findByText('3 keys · 0.05 XLM'); + expect( + within(holdingsSection).getByText('2 keys · 0.12 XLM') + ).toBeInTheDocument(); + + // No skeleton placeholders remain after data loads. + await waitFor(() => { + expect( + within(holdingsSection).queryByTestId('holdings-list-skeleton') + ).not.toBeInTheDocument(); + }); + expect( + within(holdingsSection).queryAllByTestId('holdings-skeleton-item') + ).toHaveLength(0); + }); +}); diff --git a/src/pages/__tests__/LandingPage.holdingsSellBalanceUpdate.integration.test.tsx b/src/pages/__tests__/LandingPage.holdingsSellBalanceUpdate.integration.test.tsx new file mode 100644 index 00000000..d5653191 --- /dev/null +++ b/src/pages/__tests__/LandingPage.holdingsSellBalanceUpdate.integration.test.tsx @@ -0,0 +1,261 @@ +/** + * Integration test for holdings page showing updated balance after a successful sell (#574). + */ +import type { ComponentProps, ReactNode } from 'react'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { MemoryRouter } from 'react-router'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import LandingPage from '@/pages/LandingPage'; +import { courseService, type Course } from '@/services/course.service'; + +vi.mock('@/hooks/useWallet', () => ({ + useTradeMutation: () => ({ mutateAsync: vi.fn(), isPending: false }), + useWalletHoldings: () => ({ data: [] }), +})); + +vi.mock('@/services/course.service', () => ({ + courseService: { getCourses: vi.fn() }, +})); + +vi.mock('@/hooks/useNetworkMismatch', () => ({ + useNetworkMismatch: () => ({ + isMismatch: false, + expectedChainName: 'Stellar Testnet', + }), +})); + +vi.mock('@/hooks/useStaleData', () => ({ + useStaleData: () => ({ + stale: false, + ageMs: 0, + msUntilStale: 60_000, + revalidate: vi.fn(), + }), +})); + +vi.mock('@/components/common/StellarConnectionQualityBadge', async () => { + const React = await import('react'); + + return { + default: () => React.createElement('div', { role: 'status' }, 'RPC good'), + }; +}); + +vi.mock('@/components/common/CreatorCard', async () => { + const React = await import('react'); + + return { + default: ({ creator }: { creator: { title: string } }) => + React.createElement( + 'article', + { 'aria-label': `Creator ${creator.title}` }, + creator.title + ), + }; +}); + +vi.mock('@/components/common/FeaturedCreatorAudienceChip', async () => { + const React = await import('react'); + + return { + FeaturedCreatorAudienceChip: () => + React.createElement('div', { 'data-testid': 'mock-audience-chip' }), + }; +}); + +vi.mock('framer-motion', async () => { + const React = await import('react'); + type MotionDivProps = ComponentProps<'div'> & { + layout?: boolean; + transition?: unknown; + }; + + return { + AnimatePresence: ({ children }: { children: ReactNode }) => + React.createElement(React.Fragment, null, children), + LayoutGroup: ({ children }: { children: ReactNode }) => + React.createElement(React.Fragment, null, children), + motion: { + div: ({ children, ...props }: MotionDivProps) => { + const { layout, transition, ...divProps } = props; + void layout; + void transition; + + return React.createElement('div', divProps, children); + }, + h1: ({ children, ...props }: ComponentProps<'h1'>) => + React.createElement('h1', props, children), + button: ({ children, ...props }: ComponentProps<'button'>) => + React.createElement('button', props, children), + }, + }; +}); + +const mockGetCourses = vi.mocked(courseService.getCourses); + +const singleCreator: Course[] = [ + { + id: 'creator-a', + title: 'Creator A', + description: 'Digital artist', + price: 0.05, + priceStroops: 500_000, + creatorShareSupply: 100, + instructorId: 'creator-a', + category: 'Art', + level: 'BEGINNER', + isVerified: true, + }, +]; + +const twoCreators: Course[] = [ + { + id: 'creator-a', + title: 'Creator A', + description: 'Digital artist', + price: 0.05, + priceStroops: 500_000, + creatorShareSupply: 100, + instructorId: 'creator-a', + category: 'Art', + level: 'BEGINNER', + isVerified: true, + }, + { + id: 'creator-b', + title: 'Creator B', + description: 'Developer', + price: 0.12, + priceStroops: 1_200_000, + creatorShareSupply: 50, + instructorId: 'creator-b', + category: 'Tech', + level: 'ADVANCED', + isVerified: false, + }, +]; + +const mockMatchMedia = () => { + Object.defineProperty(window, 'matchMedia', { + writable: true, + value: vi.fn().mockImplementation((query: string) => ({ + matches: false, + media: query, + onchange: null, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + addListener: vi.fn(), + removeListener: vi.fn(), + dispatchEvent: vi.fn(), + })), + }); +}; + +const confirmTrade = (side: 'Buy' | 'Sell', amount: number) => { + const [target] = screen.getAllByRole('button', { name: side }); + fireEvent.click(target); + + const amountInput = screen.getByTestId('trade-dialog-amount'); + fireEvent.change(amountInput, { target: { value: String(amount) } }); + + const confirmButton = screen.getByTestId('trade-dialog-confirm'); + fireEvent.click(confirmButton); +}; + +describe('Holdings page sell balance update (#574)', () => { + beforeEach(() => { + mockMatchMedia(); + window.localStorage.clear(); + window.sessionStorage.clear(); + mockGetCourses.mockReset(); + }); + + it('decrements_holding_quantity_after_partial_sell_confirmation', async () => { + mockGetCourses.mockResolvedValue(singleCreator); + + render( + + + + ); + + await screen.findByText('3 keys · 0.05 XLM'); + + confirmTrade('Sell', 1); + + await waitFor( + () => { + expect(screen.getByText('2 keys · 0.05 XLM')).toBeInTheDocument(); + }, + { timeout: 5000 } + ); + expect(screen.queryByText('3 keys · 0.05 XLM')).toBeNull(); + }); + + it('removes_holding_entry_after_full_sell_confirmation', async () => { + mockGetCourses.mockResolvedValue(singleCreator); + + render( + + + + ); + + await screen.findByText('3 keys · 0.05 XLM'); + + confirmTrade('Sell', 3); + + await waitFor( + () => { + expect(screen.queryByText('3 keys · 0.05 XLM')).toBeNull(); + }, + { timeout: 5000 } + ); + expect(screen.getByText('No held creator keys yet.')).toBeInTheDocument(); + }); + + it('updates_holdings_without_manual_refresh', async () => { + mockGetCourses.mockResolvedValue(singleCreator); + + render( + + + + ); + + await screen.findByText('3 keys · 0.05 XLM'); + + confirmTrade('Sell', 1); + + // Assert updated quantity appears in DOM without manual refetch/reload/re-render trigger + await waitFor( + () => { + expect(screen.getByText('2 keys · 0.05 XLM')).toBeInTheDocument(); + }, + { timeout: 5000 } + ); + }); + + it('partial_sell_leaves_other_holdings_unaffected', async () => { + mockGetCourses.mockResolvedValue(twoCreators); + + render( + + + + ); + + await screen.findByText('3 keys · 0.05 XLM'); + expect(screen.getByText('2 keys · 0.12 XLM')).toBeInTheDocument(); + + confirmTrade('Sell', 1); + + await waitFor( + () => { + expect(screen.getByText('2 keys · 0.05 XLM')).toBeInTheDocument(); + }, + { timeout: 5000 } + ); + expect(screen.getByText('2 keys · 0.12 XLM')).toBeInTheDocument(); + }); +}); diff --git a/src/pages/__tests__/LandingPage.holdingsTradeSequence.integration.test.tsx b/src/pages/__tests__/LandingPage.holdingsTradeSequence.integration.test.tsx new file mode 100644 index 00000000..7706be48 --- /dev/null +++ b/src/pages/__tests__/LandingPage.holdingsTradeSequence.integration.test.tsx @@ -0,0 +1,249 @@ +/** + * Integration test for holdings entry count after buy/sell sequence (#TBD). + * + * The holdings list should add an entry when a buy confirms for a new creator + * and remove it when all keys for that creator are sold. Additional keys for + * an existing creator must not produce a duplicate entry. + * + * Held positions are derived from the API response: + * index 0 → featuredHoldings (mutable via TradeDialog, starts at 3) + * index 1+ → DEMO_HELD_KEY_QUANTITIES (fixed per session) + * + * Flow (2 creators returned by the API): + * 1. Initial: featuredHoldings=3 (Creator A) + 2 keys (Creator B) → 2 entries + * 2. Sell all 3 keys of Creator A → 1 entry (only Creator B) + * 3. Buy 1 key of Creator A → 2 entries (A re-added, B still present) + * 4. Buy 2 more keys of Creator A → still 2 entries (no duplicate) + * 5. Sell all 3 keys of Creator A → 1 entry (only Creator B again) + * + * Acceptance criteria covered: + * - Entry added when a buy confirms for a new creator (step 3) + * - Entry not duplicated when additional keys are bought (step 4) + * - Entry removed when all keys for a creator are sold (steps 2, 5) + * - Entry count correct after each state change (all steps) + */ +import type { ComponentProps, ReactNode } from 'react'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { MemoryRouter } from 'react-router'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import LandingPage from '@/pages/LandingPage'; +import { courseService, type Course } from '@/services/course.service'; + +vi.mock('@/services/course.service', () => ({ + courseService: { getCourses: vi.fn() }, +})); + +vi.mock('@/hooks/useNetworkMismatch', () => ({ + useNetworkMismatch: () => ({ + isMismatch: false, + expectedChainName: 'Stellar Testnet', + }), +})); + +vi.mock('@/hooks/useStaleData', () => ({ + useStaleData: () => ({ + stale: false, + ageMs: 0, + msUntilStale: 60_000, + revalidate: vi.fn(), + }), +})); + +vi.mock('@/components/common/StellarConnectionQualityBadge', async () => { + const React = await import('react'); + + return { + default: () => React.createElement('div', { role: 'status' }, 'RPC good'), + }; +}); + +vi.mock('@/components/common/CreatorCard', async () => { + const React = await import('react'); + + return { + default: ({ creator }: { creator: { title: string } }) => + React.createElement( + 'article', + { 'aria-label': `Creator ${creator.title}` }, + creator.title + ), + }; +}); + +vi.mock('@/components/common/FeaturedCreatorAudienceChip', async () => { + const React = await import('react'); + + return { + FeaturedCreatorAudienceChip: () => + React.createElement('div', { 'data-testid': 'mock-audience-chip' }), + }; +}); + +vi.mock('framer-motion', async () => { + const React = await import('react'); + type MotionDivProps = ComponentProps<'div'> & { + layout?: boolean; + transition?: unknown; + }; + + return { + AnimatePresence: ({ children }: { children: ReactNode }) => + React.createElement(React.Fragment, null, children), + LayoutGroup: ({ children }: { children: ReactNode }) => + React.createElement(React.Fragment, null, children), + motion: { + div: ({ children, ...props }: MotionDivProps) => { + const { layout, transition, ...divProps } = props; + void layout; + void transition; + + return React.createElement('div', divProps, children); + }, + h1: ({ children, ...props }: ComponentProps<'h1'>) => + React.createElement('h1', props, children), + button: ({ children, ...props }: ComponentProps<'button'>) => + React.createElement('button', props, children), + }, + }; +}); + +const mockGetCourses = vi.mocked(courseService.getCourses); + +const twoCreators: Course[] = [ + { + id: 'creator-a', + title: 'Creator A', + description: 'Digital artist', + price: 0.05, + priceStroops: 500_000, + creatorShareSupply: 100, + instructorId: 'creator-a', + category: 'Art', + level: 'BEGINNER', + isVerified: true, + }, + { + id: 'creator-b', + title: 'Creator B', + description: 'Developer', + price: 0.12, + priceStroops: 1_200_000, + creatorShareSupply: 50, + instructorId: 'creator-b', + category: 'Tech', + level: 'ADVANCED', + isVerified: false, + }, +]; + +const mockMatchMedia = () => { + Object.defineProperty(window, 'matchMedia', { + writable: true, + value: vi.fn().mockImplementation((query: string) => ({ + matches: false, + media: query, + onchange: null, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + addListener: vi.fn(), + removeListener: vi.fn(), + dispatchEvent: vi.fn(), + })), + }); +}; + +const getHoldingsHeaderEntryCount = () => + Number(screen.getByTestId('holdings-header-entry-count').textContent); + +const waitForHoldingsHeaderCount = async (count: number) => { + await waitFor( + () => { + expect(getHoldingsHeaderEntryCount()).toBe(count); + }, + { timeout: 3000 } + ); +}; + +/** + * Opens the trade dialog by clicking the given labelled button (Buy or Sell) + * and confirms a trade for the given amount. + * + * Desktop Buy/Sell buttons are hidden at narrow viewports. When matchMedia + * reports a sub-md viewport, only the mobile bottom-bar buttons are visible. + * Both sets invoke the same handler, so picking either is fine. + */ +const confirmTrade = (side: 'Buy' | 'Sell', amount: number) => { + const [target] = screen.getAllByRole('button', { name: side }); + fireEvent.click(target); + + const amountInput = screen.getByTestId('trade-dialog-amount'); + fireEvent.change(amountInput, { target: { value: String(amount) } }); + + const confirmButton = screen.getByTestId('trade-dialog-confirm'); + fireEvent.click(confirmButton); +}; + +/** + * Waits for any open Radix dialog to close by asserting the dialog role + * has been removed from the DOM. This is necessary because the dialog + * applies aria-hidden to all siblings, which causes getByRole queries + * in subsequent confirmTrade calls to fail. + */ +const waitForDialogToClose = async () => { + await waitFor( + () => { + expect(screen.queryByRole('dialog')).toBeNull(); + }, + { timeout: 3000 } + ); +}; + +describe('LandingPage holdings entry count after buy/sell sequence', () => { + beforeEach(() => { + mockMatchMedia(); + window.localStorage.clear(); + window.sessionStorage.clear(); + mockGetCourses.mockReset(); + }); + + it( + 'updates entry count correctly after sell and buy sequence', + async () => { + mockGetCourses.mockResolvedValue(twoCreators); + + render( + + + + + + ); + + await waitFor(() => expect(mockGetCourses).toHaveBeenCalledTimes(1)); + + // Initial: featuredHoldings=3 (Creator A) + 2 keys (Creator B) = 2 entries + await waitForHoldingsHeaderCount(2); + + // Step 1: Sell all 3 keys of Creator A → featuredHoldings=0 → 1 entry (B only) + confirmTrade('Sell', 3); + await waitForHoldingsHeaderCount(1); + await waitForDialogToClose(); + + // Step 2: Buy 1 key for Creator A → featuredHoldings=1 → 2 entries + confirmTrade('Buy', 1); + await waitForHoldingsHeaderCount(2); + await waitForDialogToClose(); + + // Step 3: Buy 2 more keys for Creator A → featuredHoldings=3 → still 2 entries (no duplicate) + confirmTrade('Buy', 2); + await waitForHoldingsHeaderCount(2); + await waitForDialogToClose(); + + // Step 4: Sell all 3 keys of Creator A → featuredHoldings=0 → 1 entry (B only) + confirmTrade('Sell', 3); + await waitForHoldingsHeaderCount(1); + }, + 30_000 + ); +}); diff --git a/src/pages/__tests__/LandingPage.holdingsWalletSwitch.integration.test.tsx b/src/pages/__tests__/LandingPage.holdingsWalletSwitch.integration.test.tsx new file mode 100644 index 00000000..c11716b9 --- /dev/null +++ b/src/pages/__tests__/LandingPage.holdingsWalletSwitch.integration.test.tsx @@ -0,0 +1,384 @@ +/** + * Integration test for holdings page refetching data after wallet switch (#681). + * + * Confirms that when a user switches from wallet A to wallet B (or an empty wallet), + * holdings query refetches data for the new address, displays wallet B's holdings, + * and clears wallet A's stale holdings without throwing runtime errors. + */ +import type { ComponentProps, ReactNode } from 'react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { render, screen, waitFor } from '@testing-library/react'; +import { MemoryRouter } from 'react-router'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { useAccount } from 'wagmi'; +import LandingPage from '@/pages/LandingPage'; +import { courseService, type Course } from '@/services/course.service'; +import type { HeldKeyPosition } from '@/utils/portfolioValue.utils'; + +// --------------------------------------------------------------------------- +// Mocks & Setup +// --------------------------------------------------------------------------- + +const EMPTY_HOLDINGS_ENTRY: { data: HeldKeyPosition[]; isError: boolean; error: Error | null } = { + data: [], + isError: false, + error: null, +}; + +const holdingsStore = vi.hoisted(() => { + let store: Record = {}; + const listeners = new Set<() => void>(); + + return { + get: (address: string) => store[address] ?? EMPTY_HOLDINGS_ENTRY, + set: (address: string, holdings: HeldKeyPosition[], isError = false, error: Error | null = null) => { + store[address] = { data: holdings, isError, error }; + listeners.forEach(l => l()); + }, + reset: () => { + store = {}; + listeners.forEach(l => l()); + }, + subscribe: (listener: () => void) => { + listeners.add(listener); + return () => listeners.delete(listener); + }, + }; +}); + +vi.mock('wagmi', () => ({ + useAccount: vi.fn(() => ({ address: undefined, isConnected: false })), +})); + +vi.mock('@/hooks/useWallet', async () => { + const React = await import('react'); + return { + useWalletHoldings: (address: string) => { + const entry = React.useSyncExternalStore( + holdingsStore.subscribe, + () => holdingsStore.get(address), + () => holdingsStore.get(address) + ); + return { + data: entry.data, + isError: entry.isError, + error: entry.error, + }; + }, + useTradeMutation: () => ({ mutateAsync: vi.fn(), isPending: false }), + }; +}); + +vi.mock('@/services/course.service', () => ({ + courseService: { getCourses: vi.fn() }, +})); + +vi.mock('@/hooks/useNetworkMismatch', () => ({ + useNetworkMismatch: () => ({ + isMismatch: false, + expectedChainName: 'Stellar Testnet', + }), +})); + +vi.mock('@/hooks/useStaleData', () => ({ + useStaleData: () => ({ + stale: false, + ageMs: 0, + msUntilStale: 60_000, + revalidate: vi.fn(), + }), +})); + +vi.mock('@/components/common/StellarConnectionQualityBadge', async () => { + const React = await import('react'); + return { + default: () => React.createElement('div', { role: 'status' }, 'RPC good'), + }; +}); + +vi.mock('@/components/common/CreatorCard', async () => { + const React = await import('react'); + return { + default: ({ creator }: { creator: { title: string } }) => + React.createElement( + 'article', + { 'aria-label': `Creator ${creator.title}` }, + creator.title + ), + }; +}); + +vi.mock('@/components/common/FeaturedCreatorAudienceChip', async () => { + const React = await import('react'); + return { + FeaturedCreatorAudienceChip: () => + React.createElement('div', { 'data-testid': 'mock-audience-chip' }), + }; +}); + +vi.mock('framer-motion', async () => { + const React = await import('react'); + type MotionDivProps = ComponentProps<'div'> & { + layout?: boolean; + transition?: unknown; + }; + + return { + AnimatePresence: ({ children }: { children: ReactNode }) => + React.createElement(React.Fragment, null, children), + LayoutGroup: ({ children }: { children: ReactNode }) => + React.createElement(React.Fragment, null, children), + motion: { + div: ({ children, ...props }: MotionDivProps) => { + const { layout, transition, ...divProps } = props; + void layout; + void transition; + return React.createElement('div', divProps, children); + }, + h1: ({ children, ...props }: ComponentProps<'h1'>) => + React.createElement('h1', props, children), + button: ({ children, ...props }: ComponentProps<'button'>) => + React.createElement('button', props, children), + }, + }; +}); + +const mockUseAccount = vi.mocked(useAccount); +const mockGetCourses = vi.mocked(courseService.getCourses); + +const WALLET_A = '0x1111111111111111111111111111111111111111'; +const WALLET_B = '0x2222222222222222222222222222222222222222'; +const WALLET_EMPTY = '0x0000000000000000000000000000000000000000'; + +const testCreators: Course[] = [ + { + id: 'creator-alpha', + title: 'Creator Alpha', + description: 'Alpha creator', + price: 0.1, + priceStroops: 1_000_000, + creatorShareSupply: 100, + instructorId: 'alpha', + category: 'Art', + level: 'BEGINNER', + isVerified: true, + }, + { + id: 'creator-beta', + title: 'Creator Beta', + description: 'Beta creator', + price: 0.2, + priceStroops: 2_000_000, + creatorShareSupply: 50, + instructorId: 'beta', + category: 'Tech', + level: 'ADVANCED', + isVerified: true, + }, + { + id: 'creator-gamma', + title: 'Creator Gamma', + description: 'Gamma creator', + price: 0.3, + priceStroops: 3_000_000, + creatorShareSupply: 75, + instructorId: 'gamma', + category: 'Finance', + level: 'INTERMEDIATE', + isVerified: true, + }, +]; + +const mockMatchMedia = () => { + Object.defineProperty(window, 'matchMedia', { + writable: true, + value: vi.fn().mockImplementation((query: string) => ({ + matches: false, + media: query, + onchange: null, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + addListener: vi.fn(), + removeListener: vi.fn(), + dispatchEvent: vi.fn(), + })), + }); +}; + +const getHoldingsOverviewSection = () => { + const heading = screen.getByRole('heading', { name: 'Total portfolio value' }); + const section = heading.closest('[aria-labelledby="holdings-overview-heading"]'); + expect(section).not.toBeNull(); + return section as HTMLElement; +}; + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('LandingPage holdings refetch on wallet switch (#681)', () => { + let queryClient: QueryClient; + + beforeEach(() => { + mockMatchMedia(); + window.localStorage.clear(); + window.sessionStorage.clear(); + mockGetCourses.mockReset(); + mockGetCourses.mockResolvedValue(testCreators); + holdingsStore.reset(); + + queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }); + }); + + it('refetches and updates holdings when switching from Wallet A to Wallet B', async () => { + // Seed Wallet A holdings (2 creators: Alpha with 5 keys & Beta with 2 keys) + holdingsStore.set(WALLET_A, [ + { creatorId: 'creator-alpha', quantity: 5, priceStroops: 1_000_000, price: 0.1, pending: false }, + { creatorId: 'creator-beta', quantity: 2, priceStroops: 2_000_000, price: 0.2, pending: false }, + ]); + + // Seed Wallet B holdings (1 creator: Gamma with 10 keys) + holdingsStore.set(WALLET_B, [ + { creatorId: 'creator-gamma', quantity: 10, priceStroops: 3_000_000, price: 0.3, pending: false }, + ]); + + // 1. Connect Wallet A + mockUseAccount.mockReturnValue({ + address: WALLET_A, + isConnected: true, + } as ReturnType); + + const { rerender } = render( + + + + + + ); + + // Assert Wallet A holdings are shown + await waitFor(() => { + expect(screen.getByText('5 keys · 0.1 XLM')).toBeInTheDocument(); + }); + expect(screen.getByText('2 keys · 0.2 XLM')).toBeInTheDocument(); + + // 2. Switch to Wallet B + mockUseAccount.mockReturnValue({ + address: WALLET_B, + isConnected: true, + } as ReturnType); + + rerender( + + + + + + ); + + // Assert Wallet B holdings are shown + await waitFor(() => { + expect(screen.getByText('10 keys · 0.3 XLM')).toBeInTheDocument(); + }); + + // Assert Wallet A holdings are absent after switch + expect(screen.queryByText('5 keys · 0.1 XLM')).not.toBeInTheDocument(); + expect(screen.queryByText('2 keys · 0.2 XLM')).not.toBeInTheDocument(); + }); + + it('handles switching to an empty wallet address smoothly without displaying stale holdings', async () => { + holdingsStore.set(WALLET_A, [ + { creatorId: 'creator-alpha', quantity: 3, priceStroops: 1_000_000, price: 0.1, pending: false }, + ]); + holdingsStore.set(WALLET_EMPTY, []); + + mockUseAccount.mockReturnValue({ + address: WALLET_A, + isConnected: true, + } as ReturnType); + + const { rerender } = render( + + + + + + ); + + await waitFor(() => { + expect(screen.getByText('3 keys · 0.1 XLM')).toBeInTheDocument(); + }); + + // Switch to empty wallet + mockUseAccount.mockReturnValue({ + address: WALLET_EMPTY, + isConnected: true, + } as ReturnType); + + rerender( + + + + + + ); + + // Assert transition is smooth and clears wallet A holdings + await waitFor(() => { + expect(screen.queryByText('3 keys · 0.1 XLM')).not.toBeInTheDocument(); + }); + expect(getHoldingsOverviewSection()).toBeInTheDocument(); + }); + + it('negative test — handles holdings query failure for new wallet without displaying stale holdings', async () => { + holdingsStore.set(WALLET_A, [ + { creatorId: 'creator-alpha', quantity: 4, priceStroops: 1_000_000, price: 0.1, pending: false }, + ]); + + // Set Wallet B to an error state + holdingsStore.set(WALLET_B, [], true, new Error('Network error fetching holdings')); + + mockUseAccount.mockReturnValue({ + address: WALLET_A, + isConnected: true, + } as ReturnType); + + const { rerender } = render( + + + + + + ); + + await waitFor(() => { + expect(screen.getByText('4 keys · 0.1 XLM')).toBeInTheDocument(); + }); + + // Switch to Wallet B (whose query fails) + mockUseAccount.mockReturnValue({ + address: WALLET_B, + isConnected: true, + } as ReturnType); + + rerender( + + + + + + ); + + // Assert Wallet A's holdings are immediately removed and not displayed as stale data + await waitFor(() => { + expect(screen.queryByText('4 keys · 0.1 XLM')).not.toBeInTheDocument(); + }); + expect(getHoldingsOverviewSection()).toBeInTheDocument(); + }); +}); diff --git a/src/pages/__tests__/LandingPage.keyboard.test.tsx b/src/pages/__tests__/LandingPage.keyboard.test.tsx index 4d7f56c9..f24da322 100644 --- a/src/pages/__tests__/LandingPage.keyboard.test.tsx +++ b/src/pages/__tests__/LandingPage.keyboard.test.tsx @@ -98,8 +98,14 @@ const mockMatchMedia = () => { }); }; +import { MemoryRouter } from 'react-router'; + const renderLandingPage = async () => { - render(); + render( + + + + ); await waitFor(() => expect(mockGetCourses).toHaveBeenCalledTimes(1)); }; diff --git a/src/pages/__tests__/LandingPage.portfolio.test.tsx b/src/pages/__tests__/LandingPage.portfolio.test.tsx new file mode 100644 index 00000000..11df9ee8 --- /dev/null +++ b/src/pages/__tests__/LandingPage.portfolio.test.tsx @@ -0,0 +1,374 @@ +import React from 'react'; +import { render, screen, waitFor } from '@testing-library/react'; +import { MemoryRouter } from 'react-router'; +import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'; +import LandingPage from '@/pages/LandingPage'; +import { courseService } from '@/services/course.service'; +import type { Course } from '@/services/course.service'; + +// Mock the course service +vi.mock('@/services/course.service', () => ({ + courseService: { + getCourses: vi.fn(), + }, +})); + +// Mock framer-motion to avoid animation complexity in tests +vi.mock('framer-motion', () => ({ + motion: { + div: ({ children, ...props }: React.HTMLAttributes) =>
{children}
, + button: ({ children, ...props }: React.ButtonHTMLAttributes) => ( + + ), + }, + AnimatePresence: ({ children }: React.PropsWithChildren) => children, +})); + +// Mock GSAP to avoid animation errors +vi.mock('gsap', () => ({ + default: { + timeline: vi.fn(() => ({ + to: vi.fn().mockReturnThis(), + from: vi.fn().mockReturnThis(), + fromTo: vi.fn().mockReturnThis(), + })), + to: vi.fn(), + from: vi.fn(), + fromTo: vi.fn(), + }, +})); + +const createMockCourse = ( + id: string, + priceStroops: number | null, + price?: number | null +): Course => ({ + id, + title: `Creator ${id}`, + description: `Description for ${id}`, + price: price ?? 0, + priceStroops: priceStroops ?? undefined, + instructorId: id, + category: 'Technology', + level: 'BEGINNER', + isVerified: true, +}); + +describe('LandingPage - Portfolio Total XLM Value', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('correctly sums total XLM value for a portfolio with two positions', async () => { + // Position 0: 3 keys (featuredHoldings) @ 500,000 stroops = 1,500,000 stroops (0.15 XLM) + // Position 1: 2 keys (DEMO_HELD_KEY_QUANTITIES[1]) @ 1,200,000 stroops = 2,400,000 stroops (0.24 XLM) + // Total: 3,900,000 stroops = 0.39 XLM + const mockCourses: Course[] = [ + createMockCourse('creator1', 500_000), + createMockCourse('creator2', 1_200_000), + ]; + + vi.mocked(courseService.getCourses).mockResolvedValue(mockCourses); + + render( + + + + ); + + // Wait for the portfolio value to be calculated and displayed + await waitFor( + () => { + const portfolioDisplay = screen.getByText(/XLM/); + expect(portfolioDisplay).toBeInTheDocument(); + }, + { timeout: 3000 } + ); + + // The first position gets featuredHoldings (3), second gets DEMO_HELD_KEY_QUANTITIES[1] (2) + // Expected: (3 * 500,000) + (2 * 1,200,000) = 3,900,000 stroops = 0.39 XLM + const portfolioValue = screen.getByText(/0\.39 XLM/i); + expect(portfolioValue).toBeInTheDocument(); + + // Verify helper text shows 2 positions + expect(screen.getByText(/across 2 held creator positions/i)).toBeInTheDocument(); + }); + + it('excludes positions with zero quantity from the total', async () => { + // Position 0: 3 keys (featuredHoldings) @ 500,000 stroops = 1,500,000 stroops (0.15 XLM) + // Position 1: 2 keys @ 1,200,000 stroops = 2,400,000 stroops (0.24 XLM) + // Position 2: 1 key @ 800_000 stroops = 800,000 stroops (0.08 XLM) + // Position 3: 0 keys (DEMO_HELD_KEY_QUANTITIES[3] = undefined = 0) @ 900,000 = 0 (excluded) + // Total: 4,700,000 stroops = 0.47 XLM + const mockCourses: Course[] = [ + createMockCourse('creator1', 500_000), + createMockCourse('creator2', 1_200_000), + createMockCourse('creator3', 800_000), + createMockCourse('creator4', 900_000), // This will have 0 quantity (index 3, no entry in array) + ]; + + vi.mocked(courseService.getCourses).mockResolvedValue(mockCourses); + + render( + + + + ); + + await waitFor( + () => { + const portfolioDisplay = screen.getByText(/XLM/); + expect(portfolioDisplay).toBeInTheDocument(); + }, + { timeout: 3000 } + ); + + // DEMO_HELD_KEY_QUANTITIES = [0, 2, 1], so index 0 gets featuredHoldings (3) + // Total should be: 3*500,000 + 2*1,200,000 + 1*800,000 = 4,700,000 stroops = 0.47 XLM + const portfolioValue = screen.getByText(/0\.47 XLM/i); + expect(portfolioValue).toBeInTheDocument(); + + // Verify helper text shows 3 held positions (4th is excluded) + expect(screen.getByText(/across 3 held creator positions/i)).toBeInTheDocument(); + }); + + it('displays 0 XLM when all positions have price of 0', async () => { + // All positions have 0 price + const mockCourses: Course[] = [ + createMockCourse('creator1', 0), + createMockCourse('creator2', 0), + ]; + + vi.mocked(courseService.getCourses).mockResolvedValue(mockCourses); + + render( + + + + ); + + await waitFor( + () => { + const portfolioDisplay = screen.getByText(/0 XLM/i); + expect(portfolioDisplay).toBeInTheDocument(); + }, + { timeout: 3000 } + ); + + // Should show the positions are held but value is 0 + expect(screen.getByText(/across \d+ held creator position/i)).toBeInTheDocument(); + }); + + it('updates total reactively when a position price changes', async () => { + // Initial: Position 0 @ 500,000 stroops (3 keys), Position 1 @ 1,000,000 stroops (2 keys) + // Initial total: (3 * 500,000) + (2 * 1,000,000) = 3,500,000 stroops = 0.35 XLM + const initialCourses: Course[] = [ + createMockCourse('creator1', 500_000), + createMockCourse('creator2', 1_000_000), + ]; + + vi.mocked(courseService.getCourses).mockResolvedValue(initialCourses); + + const { rerender } = render( + + + + ); + + // Wait for initial render + await waitFor( + () => { + expect(screen.getByText(/0\.35 XLM/i)).toBeInTheDocument(); + }, + { timeout: 3000 } + ); + + // Update the mock to return new prices + const updatedCourses: Course[] = [ + createMockCourse('creator1', 500_000), // Same + createMockCourse('creator2', 2_000_000), // Price doubled + ]; + + vi.mocked(courseService.getCourses).mockResolvedValue(updatedCourses); + + // Force a re-render by remounting + rerender( + + + + ); + + // The component should recalculate with new prices + // New total: (3 * 500,000) + (2 * 2,000,000) = 5,500,000 stroops = 0.55 XLM + await waitFor( + () => { + const updatedValue = screen.queryByText(/0\.55 XLM/i); + // Note: Since we're remounting the entire component, it will refetch + expect(updatedValue).toBeInTheDocument(); + }, + { timeout: 3000 } + ); + }); + + it('formats the total to proper decimal places in the UI', async () => { + // Create a scenario that results in more than 2 decimal places + // 3 keys @ 333,333 stroops = 999,999 stroops = 0.0999999 XLM + // Should display as 0.1 XLM (formatted per formatDisplayKeyPrice logic) + const mockCourses: Course[] = [createMockCourse('creator1', 333_333)]; + + vi.mocked(courseService.getCourses).mockResolvedValue(mockCourses); + + render( + + + + ); + + await waitFor( + () => { + const portfolioDisplay = screen.getByText(/XLM/); + expect(portfolioDisplay).toBeInTheDocument(); + }, + { timeout: 3000 } + ); + + // The formatDisplayKeyPrice function formats to at most 4 decimal places + // but typically rounds sensibly. Check that XLM is present and formatted. + const xlmText = screen.getByText(/[\d,]+(?:\.\d{1,4})?\s*XLM/); + expect(xlmText).toBeInTheDocument(); + + // Extract the numeric part to verify it's properly formatted + const textContent = xlmText.textContent || ''; + const match = textContent.match(/([\d,.]+)\s*XLM/); + expect(match).toBeTruthy(); + + if (match) { + const numericValue = match[1].replace(/,/g, ''); + const decimalPlaces = numericValue.includes('.') + ? numericValue.split('.')[1].length + : 0; + // Should be formatted with 4 or fewer decimal places (per formatDisplayKeyPrice) + expect(decimalPlaces).toBeLessThanOrEqual(4); + } + }); + + it('displays loading state while prices are being fetched', async () => { + // Delay the resolution to simulate loading + const mockCourses: Course[] = [ + createMockCourse('creator1', 500_000), + createMockCourse('creator2', 1_000_000), + ]; + + vi.mocked(courseService.getCourses).mockImplementation( + () => + new Promise(resolve => { + setTimeout(() => resolve(mockCourses), 100); + }) + ); + + render( + + + + ); + + // Should show loading state initially + expect(screen.getByText(/loading prices/i)).toBeInTheDocument(); + + // Wait for the data to load + await waitFor( + () => { + expect(screen.getByText(/XLM/)).toBeInTheDocument(); + }, + { timeout: 3000 } + ); + + // Loading text should be gone + expect(screen.queryByText(/loading prices/i)).not.toBeInTheDocument(); + }); + + it('handles empty portfolio (no holdings) correctly', async () => { + // Return creators but the demo quantities will all be 0 + const mockCourses: Course[] = []; + + vi.mocked(courseService.getCourses).mockResolvedValue(mockCourses); + + render( + + + + ); + + await waitFor( + () => { + // Should show 0 XLM for empty portfolio + expect(screen.getByText(/0 XLM/i)).toBeInTheDocument(); + }, + { timeout: 3000 } + ); + + // Should show helper text for no holdings + expect(screen.getByText(/no held creator keys yet/i)).toBeInTheDocument(); + }); + + it('displays unavailable state when position is missing price data', async () => { + // Position 0: 3 keys with valid price + // Position 1: 2 keys (DEMO_HELD_KEY_QUANTITIES[1]) with null price - should cause unavailable + const mockCourses: Course[] = [ + createMockCourse('creator1', 500_000), + createMockCourse('creator2', null, null), // Missing price, but has quantity = 2 + ]; + + vi.mocked(courseService.getCourses).mockResolvedValue(mockCourses); + + render( + + + + ); + + await waitFor( + () => { + // When any held position is missing price, should show "Unavailable" + // Position 1 has quantity 2 (from DEMO_HELD_KEY_QUANTITIES[1]) and null price + // So the status should be "unavailable" + expect(screen.getByText(/unavailable/i)).toBeInTheDocument(); + }, + { timeout: 3000 } + ); + + // Should show helper text about missing price data + expect( + screen.getByText(/missing current price data/i) + ).toBeInTheDocument(); + }); + + it('correctly sums when using legacy price field instead of priceStroops', async () => { + // Test backward compatibility with legacy price field + // Position 0: 3 keys (featuredHoldings) @ 1.5 XLM (legacy) = 3 * 15,000,000 stroops = 45,000,000 stroops = 4.5 XLM + const mockCourses: Course[] = [ + createMockCourse('creator1', null, 1.5), // Legacy price: 1.5 XLM + ]; + + vi.mocked(courseService.getCourses).mockResolvedValue(mockCourses); + + render( + + + + ); + + await waitFor( + () => { + // Expected: 3 * (1.5 * 10,000,000) = 45,000,000 stroops = 4.5 XLM + const portfolioDisplay = screen.getByText(/4\.5 XLM/i); + expect(portfolioDisplay).toBeInTheDocument(); + }, + { timeout: 3000 } + ); + }); +}); diff --git a/src/pages/__tests__/LandingPage.priceFilters.integration.test.tsx b/src/pages/__tests__/LandingPage.priceFilters.integration.test.tsx new file mode 100644 index 00000000..15a47589 --- /dev/null +++ b/src/pages/__tests__/LandingPage.priceFilters.integration.test.tsx @@ -0,0 +1,146 @@ +import type { ComponentProps, ReactNode } from 'react'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { MemoryRouter } from 'react-router'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import LandingPage from '@/pages/LandingPage'; +import { courseService, type Course } from '@/services/course.service'; + +vi.mock('@/services/course.service', () => ({ + courseService: { getCourses: vi.fn() }, +})); + +vi.mock('@/hooks/useNetworkMismatch', () => ({ + useNetworkMismatch: () => ({ + isMismatch: false, + expectedChainName: 'Stellar Testnet', + }), +})); + +vi.mock('@/hooks/useStaleData', () => ({ + useStaleData: () => ({ + stale: false, + ageMs: 0, + msUntilStale: 60_000, + revalidate: vi.fn(), + }), +})); + +vi.mock('@/components/common/StellarConnectionQualityBadge', async () => { + const React = await import('react'); + + return { + default: () => React.createElement('div', { role: 'status' }, 'RPC good'), + }; +}); + +vi.mock('@/components/common/CreatorCard', async () => { + const React = await import('react'); + + return { + default: ({ creator }: { creator: { title: string } }) => + React.createElement( + 'article', + { 'aria-label': `Creator ${creator.title}` }, + creator.title + ), + }; +}); + +vi.mock('framer-motion', async () => { + const React = await import('react'); + type MotionDivProps = ComponentProps<'div'> & { + layout?: boolean; + transition?: unknown; + }; + + return { + AnimatePresence: ({ children }: { children: ReactNode }) => + React.createElement(React.Fragment, null, children), + LayoutGroup: ({ children }: { children: ReactNode }) => + React.createElement(React.Fragment, null, children), + motion: { + div: ({ children, ...props }: MotionDivProps) => { + const { layout, transition, ...divProps } = props; + void layout; + void transition; + + return React.createElement('div', divProps, children); + }, + h1: ({ children, ...props }: ComponentProps<'h1'>) => + React.createElement('h1', props, children), + button: ({ children, ...props }: ComponentProps<'button'>) => + React.createElement('button', props, children), + }, + }; +}); + +const mockGetCourses = vi.mocked(courseService.getCourses); + +const creator: Course = { + id: '1', + title: 'Creator Alpha', + description: 'Digital artist', + price: 0.05, + priceStroops: 500_000, + creatorShareSupply: 100, + instructorId: 'creator-a', + category: 'Art', + level: 'BEGINNER', + isVerified: true, +}; + +const mockMatchMedia = () => { + Object.defineProperty(window, 'matchMedia', { + writable: true, + value: vi.fn().mockImplementation((query: string) => ({ + matches: false, + media: query, + onchange: null, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + addListener: vi.fn(), + removeListener: vi.fn(), + dispatchEvent: vi.fn(), + })), + }); +}; + +describe('LandingPage price filters integration (#493)', () => { + beforeEach(() => { + mockMatchMedia(); + window.localStorage.clear(); + window.sessionStorage.clear(); + mockGetCourses.mockReset(); + }); + + it('clears min and max price inputs and re-fetches creators without price params', async () => { + mockGetCourses.mockResolvedValue([creator]); + render( + + + + ); + await waitFor(() => expect(mockGetCourses).toHaveBeenCalledTimes(1)); + + const minPriceInput = screen.getByLabelText(/min price/i); + const maxPriceInput = screen.getByLabelText(/max price/i); + + fireEvent.change(minPriceInput, { target: { value: '1.5' } }); + fireEvent.change(maxPriceInput, { target: { value: '4.25' } }); + + await waitFor(() => + expect(mockGetCourses).toHaveBeenLastCalledWith({ + min_price: 1.5, + max_price: 4.25, + }) + ); + + fireEvent.click(screen.getByRole('button', { name: /^clear$/i })); + + await waitFor(() => { + expect(minPriceInput).toHaveValue(null); + expect(maxPriceInput).toHaveValue(null); + expect(mockGetCourses).toHaveBeenLastCalledWith(undefined); + }); + }); +}); diff --git a/src/pages/__tests__/LandingPage.scrollRestoreBackNav.integration.test.tsx b/src/pages/__tests__/LandingPage.scrollRestoreBackNav.integration.test.tsx new file mode 100644 index 00000000..5e657507 --- /dev/null +++ b/src/pages/__tests__/LandingPage.scrollRestoreBackNav.integration.test.tsx @@ -0,0 +1,275 @@ +/** + * Scroll and page restoration on back navigation (#639). + * + * The creator list already persists page/scroll state to sessionStorage + * (see CREATOR_PAGE_KEY / CREATOR_SCROLL_KEY in LandingPage.tsx) and + * restores it on mount. This test drives that contract through an actual + * route change — navigating to a creator profile and back — rather than + * only asserting the storage keys directly, so a regression in the mount + * lifecycle (e.g. restore effect firing too early/late) would be caught. + */ +import type { ComponentProps, ReactNode } from 'react'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { createMemoryRouter, RouterProvider } from 'react-router'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import LandingPage from '@/pages/LandingPage'; +import CreatorDetailPage from '@/pages/CreatorDetailPage'; +import { courseService, type Course } from '@/services/course.service'; + +vi.mock('@/services/course.service', () => ({ + courseService: { getCourses: vi.fn() }, +})); + +vi.mock('@/hooks/useCreators', () => ({ + useCreatorDetail: () => ({ + data: { + id: '1', + title: 'Creator A', + description: 'Bio', + isVerified: true, + creatorFeeBps: 100, + protocolFeeBps: 50, + }, + isLoading: false, + error: null, + }), +})); + +vi.mock('@/hooks/useNetworkMismatch', () => ({ + useNetworkMismatch: () => ({ + isMismatch: false, + expectedChainName: 'Stellar Testnet', + }), +})); + +vi.mock('@/hooks/useStaleData', () => ({ + useStaleData: () => ({ + stale: false, + ageMs: 0, + msUntilStale: 60_000, + revalidate: vi.fn(), + }), +})); + +vi.mock('@/components/common/StellarConnectionQualityBadge', async () => { + const React = await import('react'); + return { + default: () => React.createElement('div', { role: 'status' }, 'RPC good'), + }; +}); + +vi.mock('@/components/common/CreatorCard', async () => { + const React = await import('react'); + return { + default: ({ creator }: { creator: { title: string } }) => + React.createElement( + 'article', + { 'aria-label': `Creator ${creator.title}` }, + creator.title + ), + }; +}); + +vi.mock('framer-motion', async () => { + const React = await import('react'); + type MotionDivProps = ComponentProps<'div'> & { + layout?: boolean; + transition?: unknown; + }; + + return { + AnimatePresence: ({ children }: { children: ReactNode }) => + React.createElement(React.Fragment, null, children), + LayoutGroup: ({ children }: { children: ReactNode }) => + React.createElement(React.Fragment, null, children), + motion: { + div: ({ children, ...props }: MotionDivProps) => { + const { layout, transition, ...divProps } = props; + void layout; + void transition; + return React.createElement('div', divProps, children); + }, + h1: ({ children, ...props }: ComponentProps<'h1'>) => + React.createElement('h1', props, children), + button: ({ children, ...props }: ComponentProps<'button'>) => + React.createElement('button', props, children), + }, + }; +}); + +function makeQueryClient() { + return new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }); +} + +const mockGetCourses = vi.mocked(courseService.getCourses); + +function createCreator(id: string, title: string): Course { + return { + id, + title, + description: `Description for ${title}`, + price: 0.1, + priceStroops: 1_000_000, + creatorShareSupply: 100, + instructorId: title.toLowerCase().replace(/\s+/g, '-'), + category: 'Art', + level: 'BEGINNER', + isVerified: true, + }; +} + +const ALL_CREATORS = Array.from({ length: 7 }, (_, i) => + createCreator(String(i + 1), `Creator ${String.fromCharCode(65 + i)}`) +); + +const mockMatchMedia = () => { + Object.defineProperty(window, 'matchMedia', { + writable: true, + value: vi.fn().mockImplementation((query: string) => ({ + matches: false, + media: query, + onchange: null, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + addListener: vi.fn(), + removeListener: vi.fn(), + dispatchEvent: vi.fn(), + })), + }); +}; + +const getCreatorTitles = () => + screen.getAllByRole('article').map(node => node.textContent); + +// Newer Node versions expose a global WebStorage `localStorage` that +// shadows jsdom's and has no working methods; install a spec-compliant +// in-memory stub so this suite behaves identically on every Node version +// (see LandingPage.sellFlow.integration.test.tsx, #644). +const installStorageStub = (property: 'localStorage' | 'sessionStorage') => { + const store = new Map(); + Object.defineProperty(window, property, { + configurable: true, + writable: true, + value: { + getItem: (key: string) => store.get(String(key)) ?? null, + setItem: (key: string, value: string) => { + store.set(String(key), String(value)); + }, + removeItem: (key: string) => { + store.delete(String(key)); + }, + clear: () => store.clear(), + key: (index: number) => Array.from(store.keys())[index] ?? null, + get length() { + return store.size; + }, + }, + }); +}; + +describe('LandingPage scroll/page restore on back navigation (#639)', () => { + beforeEach(() => { + mockMatchMedia(); + installStorageStub('localStorage'); + installStorageStub('sessionStorage'); + window.localStorage.setItem('accesslayer.creator-list-mode', 'infinite'); + mockGetCourses.mockReset(); + mockGetCourses.mockResolvedValue(ALL_CREATORS); + Object.defineProperty(window, 'scrollY', { + writable: true, + configurable: true, + value: 0, + }); + window.scrollTo = vi.fn(({ top }: { top: number }) => { + Object.defineProperty(window, 'scrollY', { + writable: true, + configurable: true, + value: top, + }); + }) as typeof window.scrollTo; + }); + + function renderApp() { + const router = createMemoryRouter( + [ + { path: '/', element: }, + { path: '/creator/:id', element: }, + ], + { initialEntries: ['/'] } + ); + const queryClient = makeQueryClient(); + render( + + + + ); + return router; + } + + it('keeps page 2 results and restores scroll position after navigating to a profile and back', async () => { + const router = renderApp(); + + await waitFor(() => { + expect(getCreatorTitles()).toHaveLength(6); + }); + + const loadMoreButton = await screen.findByRole('button', { + name: /load more creators/i, + }); + fireEvent.click(loadMoreButton); + + await waitFor(() => { + expect(getCreatorTitles()).toHaveLength(7); + }); + + fireEvent.scroll(window, { target: { scrollY: 850 } }); + Object.defineProperty(window, 'scrollY', { + writable: true, + configurable: true, + value: 850, + }); + fireEvent.scroll(window); + + await waitFor(() => { + expect(window.sessionStorage.getItem('accesslayer.creator-scrollY')).toBe( + '850' + ); + }); + + router.navigate('/creator/1'); + await waitFor(() => { + expect( + screen.getByRole('heading', { name: /Creator A/i, level: 1 }) + ).toBeInTheDocument(); + }); + + router.navigate('/'); + + await waitFor(() => { + expect(screen.queryAllByRole('article').length).toBeGreaterThan(0); + }); + await waitFor(() => { + expect(getCreatorTitles()).toHaveLength(7); + }); + expect(getCreatorTitles()).toEqual([ + 'Creator A', + 'Creator B', + 'Creator C', + 'Creator D', + 'Creator E', + 'Creator F', + 'Creator G', + ]); + + await waitFor(() => { + expect(window.scrollTo).toHaveBeenCalledWith( + expect.objectContaining({ top: 850 }) + ); + }); + + expect(mockGetCourses).not.toHaveBeenCalledTimes(3); + }); +}); diff --git a/src/pages/__tests__/LandingPage.searchAndSort.integration.test.tsx b/src/pages/__tests__/LandingPage.searchAndSort.integration.test.tsx new file mode 100644 index 00000000..c4786af4 --- /dev/null +++ b/src/pages/__tests__/LandingPage.searchAndSort.integration.test.tsx @@ -0,0 +1,271 @@ +/** + * Integration test for search and sort query parameters coexisting in creator list (#594). + * + * Confirms that: + * 1. Both search and sort params are present in URL when both are set. + * 2. courseService.getCourses is called with both params in the same request. + * 3. Clearing search input removes search param from URL while preserving sort param. + * 4. Changing sort dropdown updates sort param in URL while preserving current search param. + */ +import type { ComponentProps, ReactNode } from 'react'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { MemoryRouter, useLocation } from 'react-router'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import LandingPage from '@/pages/LandingPage'; +import { + courseService, + type Course, + type GetCoursesParams, +} from '@/services/course.service'; + +vi.mock('@/services/course.service', () => ({ + courseService: { getCourses: vi.fn() }, +})); + +vi.mock('@/hooks/useNetworkMismatch', () => ({ + useNetworkMismatch: () => ({ + isMismatch: false, + expectedChainName: 'Stellar Testnet', + }), +})); + +vi.mock('@/hooks/useStaleData', () => ({ + useStaleData: () => ({ + stale: false, + ageMs: 0, + msUntilStale: 60_000, + revalidate: vi.fn(), + }), +})); + +vi.mock('@/components/common/StellarConnectionQualityBadge', async () => { + const React = await import('react'); + return { + default: () => React.createElement('div', { role: 'status' }, 'RPC good'), + }; +}); + +vi.mock('@/components/common/FeaturedCreatorAudienceChip', async () => { + const React = await import('react'); + return { + FeaturedCreatorAudienceChip: () => + React.createElement('div', null, 'Mocked Audience Chip'), + }; +}); + +vi.mock('@/components/common/CreatorCard', async () => { + const React = await import('react'); + return { + default: ({ creator }: { creator: { title: string } }) => + React.createElement( + 'article', + { 'aria-label': `Creator ${creator.title}` }, + creator.title + ), + }; +}); + +vi.mock('framer-motion', async () => { + const React = await import('react'); + type MotionDivProps = ComponentProps<'div'> & { + layout?: boolean; + transition?: unknown; + }; + + return { + AnimatePresence: ({ children }: { children: ReactNode }) => + React.createElement(React.Fragment, null, children), + LayoutGroup: ({ children }: { children: ReactNode }) => + React.createElement(React.Fragment, null, children), + motion: { + div: ({ children, ...props }: MotionDivProps) => { + const { layout, transition, ...divProps } = props; + void layout; + void transition; + return React.createElement('div', divProps, children); + }, + h1: ({ children, ...props }: ComponentProps<'h1'>) => + React.createElement('h1', props, children), + button: ({ children, ...props }: ComponentProps<'button'>) => + React.createElement('button', props, children), + }, + }; +}); + +const mockGetCourses = vi.mocked(courseService.getCourses); + +const creatorAlpha: Course = { + id: '1', + title: 'Creator Alpha', + description: 'Digital artist', + price: 0.5, + priceStroops: 5_000_000, + creatorShareSupply: 100, + instructorId: 'creator-alpha', + category: 'Art', + level: 'BEGINNER', + isVerified: true, +}; + +const creatorBeta: Course = { + id: '2', + title: 'Creator Beta', + description: 'Music producer', + price: 0.1, + priceStroops: 1_000_000, + creatorShareSupply: 50, + instructorId: 'creator-beta', + category: 'Music', + level: 'INTERMEDIATE', + isVerified: true, +}; + +const allCreators = [creatorAlpha, creatorBeta]; + +const mockMatchMedia = () => { + Object.defineProperty(window, 'matchMedia', { + writable: true, + value: vi.fn().mockImplementation((query: string) => ({ + matches: false, + media: query, + onchange: null, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + addListener: vi.fn(), + removeListener: vi.fn(), + dispatchEvent: vi.fn(), + })), + }); +}; + +const getCreatorTitles = () => + screen.getAllByRole('article').map(node => node.textContent); + +function RouteLocationTracker() { + const location = useLocation(); + return
{location.search}
; +} + +describe('LandingPage search and sort coexistence integration (#594)', () => { + beforeEach(() => { + mockMatchMedia(); + window.localStorage.clear(); + window.sessionStorage.clear(); + mockGetCourses.mockReset(); + mockGetCourses.mockImplementation(async (params?: GetCoursesParams) => { + if (params?.search === 'Alpha') return [creatorAlpha]; + if (params?.search === 'Beta') return [creatorBeta]; + return allCreators; + }); + }); + + function makeQueryClient() { + return new QueryClient({ defaultOptions: { queries: { retry: false } } }); + } + + it('coexists search and sort params in URL and passes both in single API request', async () => { + render( + + + + + + + ); + + // Initial load fetch + await waitFor(() => expect(mockGetCourses).toHaveBeenCalledTimes(1)); + expect(mockGetCourses).toHaveBeenLastCalledWith(undefined); + await waitFor(() => + expect(getCreatorTitles()).toEqual(['Creator Alpha', 'Creator Beta']) + ); + + const sortDropdown = screen.getByLabelText(/^sort$/i); + + // 1. Type search query 'Alpha' and wait for debounced fetch and DOM update + const searchInput1 = await screen.findByPlaceholderText( + /search creators by name or handle/i + ); + fireEvent.change(searchInput1, { target: { value: 'Alpha' } }); + + await waitFor(() => expect(mockGetCourses).toHaveBeenCalledTimes(2)); + expect(mockGetCourses).toHaveBeenLastCalledWith({ search: 'Alpha' }); + await waitFor(() => expect(getCreatorTitles()).toEqual(['Creator Alpha'])); + await waitFor(() => { + expect(screen.getByTestId('location-search').textContent).toContain( + 'search=Alpha' + ); + }); + + // 2. Select sort option 'price-asc' + fireEvent.change(sortDropdown, { target: { value: 'price-asc' } }); + + await waitFor(() => expect(mockGetCourses).toHaveBeenCalledTimes(3)); + expect(mockGetCourses).toHaveBeenLastCalledWith({ + search: 'Alpha', + sort: 'price-asc', + }); + await waitFor(() => { + const search = screen.getByTestId('location-search').textContent || ''; + expect(search).toContain('search=Alpha'); + expect(search).toContain('sort=price-asc'); + }); + + // Wait for search input to be mounted after fetch completes + const searchInput2 = await screen.findByPlaceholderText( + /search creators by name or handle/i + ); + + // 3. Clear search input and wait for debounced fetch without search param + fireEvent.change(searchInput2, { target: { value: '' } }); + + await waitFor(() => expect(mockGetCourses).toHaveBeenCalledTimes(4)); + expect(mockGetCourses).toHaveBeenLastCalledWith({ + sort: 'price-asc', + }); + // Sorted by price-asc (Creator Beta 0.1 ETH comes before Creator Alpha 0.5 ETH) + await waitFor(() => + expect(getCreatorTitles()).toEqual(['Creator Beta', 'Creator Alpha']) + ); + await waitFor(() => { + const search = screen.getByTestId('location-search').textContent || ''; + expect(search).not.toContain('search='); + expect(search).toContain('sort=price-asc'); + }); + + // Wait for search input to be mounted after fetch completes + const searchInput3 = await screen.findByPlaceholderText( + /search creators by name or handle/i + ); + + // 4. Type search query 'Beta' while preserving current sort + fireEvent.change(searchInput3, { target: { value: 'Beta' } }); + + await waitFor(() => expect(mockGetCourses).toHaveBeenCalledTimes(5)); + expect(mockGetCourses).toHaveBeenLastCalledWith({ + search: 'Beta', + sort: 'price-asc', + }); + await waitFor(() => expect(getCreatorTitles()).toEqual(['Creator Beta'])); + await waitFor(() => { + const search = screen.getByTestId('location-search').textContent || ''; + expect(search).toContain('search=Beta'); + expect(search).toContain('sort=price-asc'); + }); + + // 5. Change sort dropdown to 'supply-desc' while preserving current search + fireEvent.change(sortDropdown, { target: { value: 'supply-desc' } }); + + await waitFor(() => expect(mockGetCourses).toHaveBeenCalledTimes(6)); + expect(mockGetCourses).toHaveBeenLastCalledWith({ + search: 'Beta', + sort: 'supply-desc', + }); + await waitFor(() => { + const search = screen.getByTestId('location-search').textContent || ''; + expect(search).toContain('search=Beta'); + expect(search).toContain('sort=supply-desc'); + }); + }, 15_000); +}); diff --git a/src/pages/__tests__/LandingPage.searchDebounceUnit.test.tsx b/src/pages/__tests__/LandingPage.searchDebounceUnit.test.tsx new file mode 100644 index 00000000..bbd08db2 --- /dev/null +++ b/src/pages/__tests__/LandingPage.searchDebounceUnit.test.tsx @@ -0,0 +1,238 @@ +import { vi } from 'vitest'; + +vi.mock('@/services/course.service', () => ({ + courseService: { getCourses: vi.fn() }, +})); + +vi.mock('@/hooks/useNetworkMismatch', () => ({ + useNetworkMismatch: () => ({ + isMismatch: false, + expectedChainName: 'Stellar Testnet', + }), +})); + +vi.mock('@/hooks/useStaleData', () => ({ + useStaleData: () => ({ + stale: false, + ageMs: 0, + msUntilStale: 60_000, + revalidate: vi.fn(), + }), +})); + +vi.mock('@/components/common/StellarConnectionQualityBadge', () => { + return { + default: () => null, + }; +}); + +vi.mock('@/components/common/FeaturedCreatorAudienceChip', () => { + return { + FeaturedCreatorAudienceChip: () => null, + }; +}); + +vi.mock('@/components/common/CreatorCard', () => { + return { + default: () => null, + }; +}); + +vi.mock('framer-motion', () => { + return { + AnimatePresence: ({ children }: { children: React.ReactNode }) => children, + LayoutGroup: ({ children }: { children: React.ReactNode }) => children, + motion: { + div: ({ children }: { children: React.ReactNode }) => children, + h1: ({ children }: { children: React.ReactNode }) => children, + button: ({ children }: { children: React.ReactNode }) => children, + }, + }; +}); + +import { act, fireEvent, render, screen } from '@testing-library/react'; +import { MemoryRouter } from 'react-router'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import LandingPage from '@/pages/LandingPage'; +import { + courseService, + type Course, +} from '@/services/course.service'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; + +const mockGetCourses = vi.mocked(courseService.getCourses); + +const creatorAlpha: Course = { + id: '1', + title: 'Creator Alpha', + description: 'Digital artist', + price: 0.5, + priceStroops: 5_000_000, + creatorShareSupply: 100, + instructorId: 'creator-alpha', + category: 'Art', + level: 'BEGINNER', + isVerified: true, +}; + +const mockMatchMedia = () => { + Object.defineProperty(window, 'matchMedia', { + writable: true, + value: vi.fn().mockImplementation((query: string) => ({ + matches: false, + media: query, + onchange: null, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + addListener: vi.fn(), + removeListener: vi.fn(), + dispatchEvent: vi.fn(), + })), + }); +}; + +describe('LandingPage search debounce logic', () => { + let queryClient: QueryClient; + let consoleDebugSpy: ReturnType; + + beforeEach(() => { + vi.useFakeTimers(); + mockMatchMedia(); + window.localStorage.clear(); + window.sessionStorage.clear(); + mockGetCourses.mockReset(); + mockGetCourses.mockResolvedValue([creatorAlpha]); + consoleDebugSpy = vi.spyOn(console, 'debug').mockImplementation(() => {}); + queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }); + }); + + afterEach(() => { + vi.useRealTimers(); + consoleDebugSpy.mockRestore(); + }); + + it('does not immediately call the query on keystroke, and calls it once after 300ms with correct value', async () => { + render( + + + + + + ); + + // Resolve initial load fetch + await act(async () => { + await vi.advanceTimersByTimeAsync(300); + }); + expect(mockGetCourses).toHaveBeenCalledTimes(1); + mockGetCourses.mockClear(); + + const input = screen.getByPlaceholderText(/search creators by name or handle/i); + + // Type a character + act(() => { + fireEvent.change(input, { target: { value: 'a' } }); + }); + + // Check that the query is NOT called immediately + expect(mockGetCourses).not.toHaveBeenCalled(); + + // Advance timers by 300ms + await act(async () => { + await vi.advanceTimersByTimeAsync(300); + }); + + // Should call the query once with the typed value + expect(mockGetCourses).toHaveBeenCalledTimes(1); + expect(mockGetCourses).toHaveBeenCalledWith({ search: 'a' }); + }); + + it('typing three characters in quick succession triggers only one query (the last value)', async () => { + render( + + + + + + ); + + // Resolve initial load fetch + await act(async () => { + await vi.advanceTimersByTimeAsync(300); + }); + mockGetCourses.mockClear(); + + const input = screen.getByPlaceholderText(/search creators by name or handle/i); + + // Type three characters with 50ms gaps in between (well under 300ms) + act(() => { + fireEvent.change(input, { target: { value: 'a' } }); + }); + await act(async () => { + await vi.advanceTimersByTimeAsync(50); + }); + act(() => { + fireEvent.change(input, { target: { value: 'ab' } }); + }); + await act(async () => { + await vi.advanceTimersByTimeAsync(50); + }); + act(() => { + fireEvent.change(input, { target: { value: 'abc' } }); + }); + + // Query not called yet because the timer got reset + expect(mockGetCourses).not.toHaveBeenCalled(); + + // Wait 300ms for final settle + await act(async () => { + await vi.advanceTimersByTimeAsync(300); + }); + + // Should have been called exactly once with the last value + expect(mockGetCourses).toHaveBeenCalledTimes(1); + expect(mockGetCourses).toHaveBeenCalledWith({ search: 'abc' }); + }); + + it('clearing the input after 300ms triggers a query with an empty string (passes undefined parameter)', async () => { + render( + + + + + + ); + + // Resolve initial load fetches + await act(async () => { + await vi.advanceTimersByTimeAsync(300); + }); + mockGetCourses.mockClear(); + + const input = screen.getByPlaceholderText(/search creators by name or handle/i); + expect(input).toHaveValue('test'); + + // Clear input + act(() => { + fireEvent.change(input, { target: { value: '' } }); + }); + + // Not called immediately + expect(mockGetCourses).not.toHaveBeenCalled(); + + // Settle clearing by advancing timers by 300ms + await act(async () => { + await vi.advanceTimersByTimeAsync(300); + }); + + // Called once without the search parameter (undefined param mapping to empty string) + expect(mockGetCourses).toHaveBeenCalledTimes(1); + expect(mockGetCourses).toHaveBeenLastCalledWith(undefined); + }); +}); diff --git a/src/pages/__tests__/LandingPage.sellConfirmationModal.integration.test.tsx b/src/pages/__tests__/LandingPage.sellConfirmationModal.integration.test.tsx new file mode 100644 index 00000000..7a591c6e --- /dev/null +++ b/src/pages/__tests__/LandingPage.sellConfirmationModal.integration.test.tsx @@ -0,0 +1,258 @@ +/** + * Integration tests for the sell key confirmation modal's mutation-outcome + * behavior (#692): the confirm button is disabled while the sell is + * pending, and the modal stays open with an inline error toast when the + * sell fails, rather than closing as if it had succeeded. + * + * Complements LandingPage.sellFlow.integration.test.tsx (success path) and + * TradeDialog.sellPayoutDisplay.test.tsx (payout display, bonding curve + * call, disabled-prop unit coverage) — this file covers the two mutation + * outcomes that require the full LandingPage wiring (handleConfirmTrade), + * since TradeDialog itself has no mutation or toast logic of its own. + */ +import type { ComponentProps, ReactNode } from 'react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { MemoryRouter } from 'react-router'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import LandingPage from '@/pages/LandingPage'; +import { courseService, type Course } from '@/services/course.service'; +import showToast from '@/utils/toast.util'; + +vi.mock('@/services/course.service', () => ({ + courseService: { getCourses: vi.fn() }, +})); + +vi.mock('@/utils/toast.util', () => ({ + default: { + message: vi.fn(), + success: vi.fn(), + error: vi.fn(), + loading: vi.fn(), + transactionSuccess: vi.fn(), + }, +})); + +vi.mock('@/hooks/useNetworkMismatch', () => ({ + useNetworkMismatch: () => ({ + isMismatch: false, + expectedChainName: 'Stellar Testnet', + }), +})); + +vi.mock('@/hooks/useStaleData', () => ({ + useStaleData: () => ({ + stale: false, + ageMs: 0, + msUntilStale: 60_000, + revalidate: vi.fn(), + }), +})); + +vi.mock('@/components/common/StellarConnectionQualityBadge', async () => { + const React = await import('react'); + + return { + default: () => React.createElement('div', { role: 'status' }, 'RPC good'), + }; +}); + +vi.mock('@/components/common/CreatorCard', async () => { + const React = await import('react'); + + return { + default: ({ creator }: { creator: { title: string } }) => + React.createElement( + 'article', + { 'aria-label': `Creator ${creator.title}` }, + creator.title + ), + }; +}); + +vi.mock('@/components/common/FeaturedCreatorAudienceChip', async () => { + const React = await import('react'); + + return { + FeaturedCreatorAudienceChip: () => + React.createElement('div', { 'data-testid': 'mock-audience-chip' }), + }; +}); + +vi.mock('framer-motion', async () => { + const React = await import('react'); + type MotionDivProps = ComponentProps<'div'> & { + layout?: boolean; + transition?: unknown; + }; + + return { + AnimatePresence: ({ children }: { children: ReactNode }) => + React.createElement(React.Fragment, null, children), + LayoutGroup: ({ children }: { children: ReactNode }) => + React.createElement(React.Fragment, null, children), + motion: { + div: ({ children, ...props }: MotionDivProps) => { + const { layout, transition, ...divProps } = props; + void layout; + void transition; + + return React.createElement('div', divProps, children); + }, + h1: ({ children, ...props }: ComponentProps<'h1'>) => + React.createElement('h1', props, children), + button: ({ children, ...props }: ComponentProps<'button'>) => + React.createElement('button', props, children), + }, + }; +}); + +const mockGetCourses = vi.mocked(courseService.getCourses); +const mockShowToast = vi.mocked(showToast); + +const featuredCreatorOnly: Course[] = [ + { + id: '1', + title: 'Alex Rivers', + description: 'Digital Artist & Illustrator', + price: 0.05, + priceStroops: 500_000, + creatorShareSupply: 120, + instructorId: '1', + category: 'Art', + level: 'BEGINNER', + isVerified: true, + }, +]; + +const mockMatchMedia = () => { + Object.defineProperty(window, 'matchMedia', { + writable: true, + value: vi.fn().mockImplementation((query: string) => ({ + matches: false, + media: query, + onchange: null, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + addListener: vi.fn(), + removeListener: vi.fn(), + dispatchEvent: vi.fn(), + })), + }); +}; + +const installStorageStub = (property: 'localStorage' | 'sessionStorage') => { + const store = new Map(); + Object.defineProperty(window, property, { + configurable: true, + writable: true, + value: { + getItem: (key: string) => store.get(String(key)) ?? null, + setItem: (key: string, value: string) => { + store.set(String(key), String(value)); + }, + removeItem: (key: string) => { + store.delete(String(key)); + }, + clear: () => store.clear(), + key: (index: number) => Array.from(store.keys())[index] ?? null, + get length() { + return store.size; + }, + }, + }); +}; + +const renderLandingPage = () => + render( + + + + + + ); + +describe('Sell confirmation modal mutation outcomes (#692)', () => { + beforeEach(() => { + mockMatchMedia(); + installStorageStub('localStorage'); + installStorageStub('sessionStorage'); + mockGetCourses.mockReset(); + vi.clearAllMocks(); + mockGetCourses.mockResolvedValue(featuredCreatorOnly); + }); + + afterEach(() => { + cleanup(); + }); + + it('disables the confirm button while the sell is pending and re-enables it once settled', async () => { + renderLandingPage(); + await screen.findByText('3 keys · 0.05 XLM'); + + const [sellButton] = screen.getAllByRole('button', { name: 'Sell' }); + fireEvent.click(sellButton); + + const amountInput = await screen.findByTestId('trade-dialog-amount'); + fireEvent.change(amountInput, { target: { value: '1' } }); + + const confirmButton = screen.getByTestId('trade-dialog-confirm'); + expect(confirmButton).not.toBeDisabled(); + + fireEvent.click(confirmButton); + + // Still mid-flight (sell's simulated confirmation takes ~900ms+250ms) — + // the button must be disabled so a second click can't double-submit. + expect(screen.getByTestId('trade-dialog-confirm')).toBeDisabled(); + + await waitFor( + () => expect(mockShowToast.transactionSuccess).toHaveBeenCalled(), + { timeout: 5000 } + ); + }); + + it('keeps the modal open and shows an inline error toast when the sell fails, instead of closing as if it succeeded', async () => { + // showToast.loading is the first call inside handleConfirmTrade's sell + // branch; making it throw exercises the real catch block exactly as a + // genuine rendering/toast-library failure would, without needing the + // sell path (which doesn't go through tradeMutation) to have a + // separate fake failure mode invented for the test. + mockShowToast.loading.mockImplementationOnce(() => { + throw new Error('simulated toast failure'); + }); + + renderLandingPage(); + await screen.findByText('3 keys · 0.05 XLM'); + + const [sellButton] = screen.getAllByRole('button', { name: 'Sell' }); + fireEvent.click(sellButton); + + const amountInput = await screen.findByTestId('trade-dialog-amount'); + fireEvent.change(amountInput, { target: { value: '1' } }); + fireEvent.click(screen.getByTestId('trade-dialog-confirm')); + + await waitFor(() => { + expect(mockShowToast.error).toHaveBeenCalledWith( + 'The signature request failed. Please ensure your wallet is unlocked and try again.' + ); + }); + + // The modal must still be open (not closed as though the sell succeeded). + expect(screen.getByTestId('trade-dialog-amount')).toBeInTheDocument(); + expect(screen.getByTestId('trade-dialog-confirm')).toBeInTheDocument(); + + // Holdings must be unchanged — a failed sell must not decrement the balance. + expect(screen.getByText('3 keys · 0.05 XLM')).toBeInTheDocument(); + + // No success toast fired for the failed attempt. + expect(mockShowToast.transactionSuccess).not.toHaveBeenCalled(); + + // The confirm button is re-enabled after the failure settles, so the + // user can retry rather than being stuck on a disabled button. + await waitFor(() => { + expect(screen.getByTestId('trade-dialog-confirm')).not.toBeDisabled(); + }); + }); +}); diff --git a/src/pages/__tests__/LandingPage.sellFlow.integration.test.tsx b/src/pages/__tests__/LandingPage.sellFlow.integration.test.tsx new file mode 100644 index 00000000..e4c74f72 --- /dev/null +++ b/src/pages/__tests__/LandingPage.sellFlow.integration.test.tsx @@ -0,0 +1,265 @@ +/** + * End-to-end integration test for the sell flow (#644): from quantity input + * through simulated on-chain confirmation to the success toast and updated + * holdings. + * + * Mirrors the buy-flow E2E structure: the wallet layer is the app's real + * demo wallet (react-query + useWallet, no hook mocks), seeded with the + * default 3 keys for the featured creator; only external seams (course API, + * toast sink, network badges, animation) are mocked. + */ +import type { ComponentProps, ReactNode } from 'react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { MemoryRouter } from 'react-router'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import LandingPage from '@/pages/LandingPage'; +import { courseService, type Course } from '@/services/course.service'; +import showToast from '@/utils/toast.util'; + +vi.mock('@/services/course.service', () => ({ + courseService: { getCourses: vi.fn() }, +})); + +vi.mock('@/utils/toast.util', () => ({ + default: { + message: vi.fn(), + success: vi.fn(), + error: vi.fn(), + loading: vi.fn(), + transactionSuccess: vi.fn(), + }, +})); + +vi.mock('@/hooks/useNetworkMismatch', () => ({ + useNetworkMismatch: () => ({ + isMismatch: false, + expectedChainName: 'Stellar Testnet', + }), +})); + +vi.mock('@/hooks/useStaleData', () => ({ + useStaleData: () => ({ + stale: false, + ageMs: 0, + msUntilStale: 60_000, + revalidate: vi.fn(), + }), +})); + +vi.mock('@/components/common/StellarConnectionQualityBadge', async () => { + const React = await import('react'); + + return { + default: () => React.createElement('div', { role: 'status' }, 'RPC good'), + }; +}); + +vi.mock('@/components/common/CreatorCard', async () => { + const React = await import('react'); + + return { + default: ({ creator }: { creator: { title: string } }) => + React.createElement( + 'article', + { 'aria-label': `Creator ${creator.title}` }, + creator.title + ), + }; +}); + +vi.mock('@/components/common/FeaturedCreatorAudienceChip', async () => { + const React = await import('react'); + + return { + FeaturedCreatorAudienceChip: () => + React.createElement('div', { 'data-testid': 'mock-audience-chip' }), + }; +}); + +vi.mock('framer-motion', async () => { + const React = await import('react'); + type MotionDivProps = ComponentProps<'div'> & { + layout?: boolean; + transition?: unknown; + }; + + return { + AnimatePresence: ({ children }: { children: ReactNode }) => + React.createElement(React.Fragment, null, children), + LayoutGroup: ({ children }: { children: ReactNode }) => + React.createElement(React.Fragment, null, children), + motion: { + div: ({ children, ...props }: MotionDivProps) => { + const { layout, transition, ...divProps } = props; + void layout; + void transition; + + return React.createElement('div', divProps, children); + }, + h1: ({ children, ...props }: ComponentProps<'h1'>) => + React.createElement('h1', props, children), + button: ({ children, ...props }: ComponentProps<'button'>) => + React.createElement('button', props, children), + }, + }; +}); + +const mockGetCourses = vi.mocked(courseService.getCourses); +const mockShowToast = vi.mocked(showToast); + +const featuredCreatorOnly: Course[] = [ + { + id: '1', + title: 'Alex Rivers', + description: 'Digital Artist & Illustrator', + price: 0.05, + priceStroops: 500_000, + creatorShareSupply: 120, + instructorId: '1', + category: 'Art', + level: 'BEGINNER', + isVerified: true, + }, +]; + +const mockMatchMedia = () => { + Object.defineProperty(window, 'matchMedia', { + writable: true, + value: vi.fn().mockImplementation((query: string) => ({ + matches: false, + media: query, + onchange: null, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + addListener: vi.fn(), + removeListener: vi.fn(), + dispatchEvent: vi.fn(), + })), + }); +}; + +// Newer Node versions expose a global WebStorage `localStorage` that +// shadows jsdom's and has no working methods; install a spec-compliant +// in-memory stub so this suite behaves identically on every Node version. +const installStorageStub = (property: 'localStorage' | 'sessionStorage') => { + const store = new Map(); + Object.defineProperty(window, property, { + configurable: true, + writable: true, + value: { + getItem: (key: string) => store.get(String(key)) ?? null, + setItem: (key: string, value: string) => { + store.set(String(key), String(value)); + }, + removeItem: (key: string) => { + store.delete(String(key)); + }, + clear: () => store.clear(), + key: (index: number) => Array.from(store.keys())[index] ?? null, + get length() { + return store.size; + }, + }, + }); +}; + +const renderLandingPage = () => + render( + + + + + + ); + +describe('LandingPage sell flow end-to-end (#644)', () => { + beforeEach(() => { + mockMatchMedia(); + installStorageStub('localStorage'); + installStorageStub('sessionStorage'); + mockGetCourses.mockReset(); + vi.clearAllMocks(); + mockGetCourses.mockResolvedValue(featuredCreatorOnly); + }); + + afterEach(() => { + cleanup(); + }); + + it('completes the sell flow from quantity input to success toast and updated holdings', async () => { + renderLandingPage(); + + // Wallet connected with 3 keys held for the featured creator + await screen.findByText('3 keys · 0.05 XLM'); + + // Open the trade panel on the sell side + const [sellButton] = screen.getAllByRole('button', { name: 'Sell' }); + fireEvent.click(sellButton); + + // Enter quantity 2 + const amountInput = await screen.findByTestId('trade-dialog-amount'); + fireEvent.change(amountInput, { target: { value: '2' } }); + + // Submit and wait through the simulated on-chain confirmation + fireEvent.click(screen.getByTestId('trade-dialog-confirm')); + + await waitFor( + () => + expect(mockShowToast.transactionSuccess).toHaveBeenCalledWith( + 'Trade confirmed', + 'Sold 2 keys from Alex Rivers' + ), + { timeout: 5000 } + ); + + // Holdings cache reflects 1 remaining key + await waitFor( + () => expect(screen.getByText('1 keys · 0.05 XLM')).toBeInTheDocument(), + { timeout: 5000 } + ); + expect(screen.queryByText('3 keys · 0.05 XLM')).toBeNull(); + + // No error state at any stage of the flow + expect(mockShowToast.error).not.toHaveBeenCalled(); + }); + + it('reports the submitted quantity while the transaction is pending', async () => { + renderLandingPage(); + await screen.findByText('3 keys · 0.05 XLM'); + + const [sellButton] = screen.getAllByRole('button', { name: 'Sell' }); + fireEvent.click(sellButton); + fireEvent.change(await screen.findByTestId('trade-dialog-amount'), { + target: { value: '2' }, + }); + fireEvent.click(screen.getByTestId('trade-dialog-confirm')); + + expect(mockShowToast.loading).toHaveBeenCalledWith( + 'Submitting sell for 2 keys...' + ); + }); + + it('uses the singular key wording when selling exactly one', async () => { + renderLandingPage(); + await screen.findByText('3 keys · 0.05 XLM'); + + const [sellButton] = screen.getAllByRole('button', { name: 'Sell' }); + fireEvent.click(sellButton); + fireEvent.change(await screen.findByTestId('trade-dialog-amount'), { + target: { value: '1' }, + }); + fireEvent.click(screen.getByTestId('trade-dialog-confirm')); + + await waitFor( + () => + expect(mockShowToast.transactionSuccess).toHaveBeenCalledWith( + 'Trade confirmed', + 'Sold 1 key from Alex Rivers' + ), + { timeout: 5000 } + ); + }); +}); diff --git a/src/pages/__tests__/LandingPage.sort.integration.test.tsx b/src/pages/__tests__/LandingPage.sort.integration.test.tsx new file mode 100644 index 00000000..af9f3f9d --- /dev/null +++ b/src/pages/__tests__/LandingPage.sort.integration.test.tsx @@ -0,0 +1,233 @@ +import type { ComponentProps, ReactNode } from 'react'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { MemoryRouter, useLocation } from 'react-router'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import LandingPage from '@/pages/LandingPage'; +import { + courseService, + type Course, + type GetCoursesParams, +} from '@/services/course.service'; + +vi.mock('@/services/course.service', () => ({ + courseService: { getCourses: vi.fn() }, +})); + +vi.mock('@/hooks/useNetworkMismatch', () => ({ + useNetworkMismatch: () => ({ + isMismatch: false, + expectedChainName: 'Stellar Testnet', + }), +})); + +vi.mock('@/hooks/useStaleData', () => ({ + useStaleData: () => ({ + stale: false, + ageMs: 0, + msUntilStale: 60_000, + revalidate: vi.fn(), + }), +})); + +vi.mock('@/components/common/StellarConnectionQualityBadge', async () => { + const React = await import('react'); + return { + default: () => React.createElement('div', { role: 'status' }, 'RPC good'), + }; +}); + +vi.mock('@/components/common/FeaturedCreatorAudienceChip', async () => { + const React = await import('react'); + return { + FeaturedCreatorAudienceChip: () => React.createElement('div', null, 'Mocked Audience Chip'), + }; +}); + +vi.mock('@/components/common/CreatorCard', async () => { + const React = await import('react'); + return { + default: ({ creator }: { creator: { title: string } }) => + React.createElement( + 'article', + { 'aria-label': `Creator ${creator.title}` }, + creator.title + ), + }; +}); + +vi.mock('framer-motion', async () => { + const React = await import('react'); + type MotionDivProps = ComponentProps<'div'> & { + layout?: boolean; + transition?: unknown; + }; + + return { + AnimatePresence: ({ children }: { children: ReactNode }) => + React.createElement(React.Fragment, null, children), + LayoutGroup: ({ children }: { children: ReactNode }) => + React.createElement(React.Fragment, null, children), + motion: { + div: ({ children, ...props }: MotionDivProps) => { + const { layout, transition, ...divProps } = props; + void layout; + void transition; + return React.createElement('div', divProps, children); + }, + h1: ({ children, ...props }: ComponentProps<'h1'>) => + React.createElement('h1', props, children), + button: ({ children, ...props }: ComponentProps<'button'>) => + React.createElement('button', props, children), + }, + }; +}); + +const mockGetCourses = vi.mocked(courseService.getCourses); + +const creatorAlpha: Course = { + id: '1', + title: 'Creator Alpha', + description: 'Digital artist', + price: 0.5, + priceStroops: 5_000_000, + creatorShareSupply: 100, + instructorId: 'creator-alpha', + category: 'Art', + level: 'BEGINNER', + isVerified: true, +}; + +const creatorBeta: Course = { + id: '2', + title: 'Creator Beta', + description: 'Music producer', + price: 0.1, + priceStroops: 1_000_000, + creatorShareSupply: 50, + instructorId: 'creator-beta', + category: 'Music', + level: 'INTERMEDIATE', + isVerified: true, +}; + +const creatorGamma: Course = { + id: '3', + title: 'Creator Gamma', + description: 'Solidity Developer', + price: 0.3, + priceStroops: 3_000_000, + creatorShareSupply: 75, + instructorId: 'creator-gamma', + category: 'Tech', + level: 'ADVANCED', + isVerified: true, +}; + +const featuredOrder = [creatorAlpha, creatorBeta, creatorGamma]; +const priceAscOrder = [creatorBeta, creatorGamma, creatorAlpha]; + +const mockMatchMedia = () => { + Object.defineProperty(window, 'matchMedia', { + writable: true, + value: vi.fn().mockImplementation((query: string) => ({ + matches: false, + media: query, + onchange: null, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + addListener: vi.fn(), + removeListener: vi.fn(), + dispatchEvent: vi.fn(), + })), + }); +}; + +const getCreatorTitles = () => + screen.getAllByRole('article').map(node => node.textContent); + +function RouteLocationTracker() { + const location = useLocation(); + return
{location.search}
; +} + +function makeQueryClient() { + return new QueryClient({ defaultOptions: { queries: { retry: false } } }); +} + +describe('LandingPage sort dropdown integration test', () => { + beforeEach(() => { + mockMatchMedia(); + window.localStorage.clear(); + window.sessionStorage.clear(); + mockGetCourses.mockReset(); + mockGetCourses.mockImplementation(async (params?: GetCoursesParams) => { + if (params?.sort === 'price-asc') return priceAscOrder; + return featuredOrder; + }); + }); + + it('selects sort and reorders creator list to match API response, updating the URL query string', async () => { + render( + + + + + + + ); + + // Initial load gets courses in featured order + await waitFor(() => expect(mockGetCourses).toHaveBeenCalledTimes(1)); + expect(mockGetCourses).toHaveBeenLastCalledWith(undefined); + await waitFor(() => + expect(getCreatorTitles()).toEqual(['Creator Alpha', 'Creator Beta', 'Creator Gamma']) + ); + + // Select the Price sort option (Price: Low to high -> value 'price-asc') + fireEvent.change(screen.getByLabelText(/^sort$/i), { + target: { value: 'price-asc' }, + }); + + // Assert API is called with sort=price-asc in request params + await waitFor(() => expect(mockGetCourses).toHaveBeenCalledTimes(2)); + expect(mockGetCourses).toHaveBeenLastCalledWith({ sort: 'price-asc' }); + + // Assert list reorders to match the API response for price sort + await waitFor(() => + expect(getCreatorTitles()).toEqual(['Creator Beta', 'Creator Gamma', 'Creator Alpha']) + ); + + // Assert previous order is not visible + expect(getCreatorTitles()).not.toEqual(['Creator Alpha', 'Creator Beta', 'Creator Gamma']); + + // Assert dropdown selection reflected in the URL query string + await waitFor(() => + expect(screen.getByTestId('location-search')).toHaveTextContent('sort=price-asc') + ); + }); + + it('initialises sort dropdown from URL param and fetches with that sort on load', async () => { + render( + + + + + + + ); + + // Assert initial fetch uses the sort param from URL + await waitFor(() => expect(mockGetCourses).toHaveBeenCalledTimes(1)); + expect(mockGetCourses).toHaveBeenLastCalledWith({ sort: 'price-asc' }); + + // Assert dropdown shows the correct selected option + const dropdown = screen.getByLabelText(/^sort$/i) as HTMLSelectElement; + expect(dropdown.value).toBe('price-asc'); + + // Assert list renders results matching the price-sorted response + await waitFor(() => + expect(getCreatorTitles()).toEqual(['Creator Beta', 'Creator Gamma', 'Creator Alpha']) + ); + }); +}); diff --git a/src/pages/__tests__/LandingPage.sortResetPage.integration.test.tsx b/src/pages/__tests__/LandingPage.sortResetPage.integration.test.tsx new file mode 100644 index 00000000..2aab197f --- /dev/null +++ b/src/pages/__tests__/LandingPage.sortResetPage.integration.test.tsx @@ -0,0 +1,295 @@ +/** + * Integration test for creator discovery list resetting to page one when + * sort selection changes (#625). + * + * Confirms that: + * 1. Changing sort while on page 2+ resets to page 1. + * 2. Previous page results are replaced by first page of new sort. + * 3. URL query string reflects the new sort and no page cursor. + * 4. Loading state is shown during the reset fetch. + */ +import type { ComponentProps, ReactNode } from 'react'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { MemoryRouter, useLocation } from 'react-router'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import LandingPage from '@/pages/LandingPage'; +import { + courseService, + type Course, + type GetCoursesParams, +} from '@/services/course.service'; + +vi.mock('@/services/course.service', () => ({ + courseService: { getCourses: vi.fn() }, +})); + +vi.mock('@/hooks/useNetworkMismatch', () => ({ + useNetworkMismatch: () => ({ + isMismatch: false, + expectedChainName: 'Stellar Testnet', + }), +})); + +vi.mock('@/hooks/useStaleData', () => ({ + useStaleData: () => ({ + stale: false, + ageMs: 0, + msUntilStale: 60_000, + revalidate: vi.fn(), + }), +})); + +vi.mock('@/components/common/StellarConnectionQualityBadge', async () => { + const React = await import('react'); + return { + default: () => React.createElement('div', { role: 'status' }, 'RPC good'), + }; +}); + +vi.mock('@/components/common/FeaturedCreatorAudienceChip', async () => { + const React = await import('react'); + return { + FeaturedCreatorAudienceChip: () => + React.createElement('div', null, 'Mocked Audience Chip'), + }; +}); + +vi.mock('@/components/common/CreatorCard', async () => { + const React = await import('react'); + return { + default: ({ creator }: { creator: { title: string } }) => + React.createElement( + 'article', + { 'aria-label': `Creator ${creator.title}` }, + creator.title + ), + }; +}); + +vi.mock('framer-motion', async () => { + const React = await import('react'); + type MotionDivProps = ComponentProps<'div'> & { + layout?: boolean; + transition?: unknown; + }; + + return { + AnimatePresence: ({ children }: { children: ReactNode }) => + React.createElement(React.Fragment, null, children), + LayoutGroup: ({ children }: { children: ReactNode }) => + React.createElement(React.Fragment, null, children), + motion: { + div: ({ children, ...props }: MotionDivProps) => { + const { layout, transition, ...divProps } = props; + void layout; + void transition; + return React.createElement('div', divProps, children); + }, + h1: ({ children, ...props }: ComponentProps<'h1'>) => + React.createElement('h1', props, children), + button: ({ children, ...props }: ComponentProps<'button'>) => + React.createElement('button', props, children), + }, + }; +}); + +const mockGetCourses = vi.mocked(courseService.getCourses); + +function createCreator(id: string, title: string, price: number): Course { + return { + id, + title, + description: `Description for ${title}`, + price, + priceStroops: Math.round(price * 10_000_000), + creatorShareSupply: 100, + instructorId: title.toLowerCase().replace(/\s+/g, '-'), + category: 'Art', + level: 'BEGINNER', + isVerified: true, + }; +} + +// 7 creators so we have 2 pages (PAGE_SIZE = 6) +const CREATOR_A = createCreator('1', 'Creator Alpha', 0.5); +const CREATOR_B = createCreator('2', 'Creator Beta', 0.1); +const CREATOR_C = createCreator('3', 'Creator Gamma', 0.3); +const CREATOR_D = createCreator('4', 'Creator Delta', 0.8); +const CREATOR_E = createCreator('5', 'Creator Epsilon', 0.05); +const CREATOR_F = createCreator('6', 'Creator Zeta', 0.6); +const CREATOR_G = createCreator('7', 'Creator Eta', 0.2); + +const allCreatorsFeatured = [ + CREATOR_A, + CREATOR_B, + CREATOR_C, + CREATOR_D, + CREATOR_E, + CREATOR_F, + CREATOR_G, +]; + +const allCreatorsPriceAsc = [ + CREATOR_E, + CREATOR_B, + CREATOR_G, + CREATOR_C, + CREATOR_A, + CREATOR_F, + CREATOR_D, +]; + +const mockMatchMedia = () => { + Object.defineProperty(window, 'matchMedia', { + writable: true, + value: vi.fn().mockImplementation((query: string) => ({ + matches: false, + media: query, + onchange: null, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + addListener: vi.fn(), + removeListener: vi.fn(), + dispatchEvent: vi.fn(), + })), + }); +}; + +const getCreatorTitles = () => + screen.getAllByRole('article').map(node => node.textContent); + +function RouteLocationTracker() { + const location = useLocation(); + return
{location.search}
; +} + +function makeQueryClient() { + return new QueryClient({ defaultOptions: { queries: { retry: false } } }); +} + +describe('Creator list sort resets to page one (#625)', () => { + beforeEach(() => { + mockMatchMedia(); + window.localStorage.clear(); + window.sessionStorage.clear(); + mockGetCourses.mockReset(); + mockGetCourses.mockImplementation(async (params?: GetCoursesParams) => { + if (params?.sort === 'price-asc') return allCreatorsPriceAsc; + return allCreatorsFeatured; + }); + }); + + it('resets to page 1 when sort changes while on page 2', async () => { + render( + + + + + + + ); + + // Wait for initial load (featured order, page 1: 6 of 7 creators) + await waitFor(() => expect(mockGetCourses).toHaveBeenCalledTimes(1)); + expect(mockGetCourses).toHaveBeenLastCalledWith(undefined); + + // Assert page 1 shows the first 6 featured creators + await waitFor(() => { + expect(getCreatorTitles()).toHaveLength(6); + }); + expect(getCreatorTitles()).toEqual([ + 'Creator Alpha', + 'Creator Beta', + 'Creator Gamma', + 'Creator Delta', + 'Creator Epsilon', + 'Creator Zeta', + ]); + + // Navigate to page 2 by clicking the "Next" button + const nextButton = screen.getByRole('button', { + name: /go to next page/i, + }); + fireEvent.click(nextButton); + + // Assert page 2 shows Creator Eta (the 7th creator) + await waitFor(() => { + expect(getCreatorTitles()).toEqual(['Creator Eta']); + }); + + // Verify the pagination shows we're on page 2 + expect(screen.getByText(/page 2 of 2/i)).toBeInTheDocument(); + + // Now change sort to "Price: Low to high" + const sortDropdown = screen.getByLabelText(/^sort$/i); + fireEvent.change(sortDropdown, { target: { value: 'price-asc' } }); + + // Assert loading state appears during the transition + await waitFor(() => { + expect(screen.getByText(/updating results/i)).toBeInTheDocument(); + }); + + // Wait for the refetch with new sort params + await waitFor(() => expect(mockGetCourses).toHaveBeenCalledTimes(2)); + expect(mockGetCourses).toHaveBeenLastCalledWith({ sort: 'price-asc' }); + + // Assert the list now shows the first page of price-asc sorted results + await waitFor(() => { + expect(getCreatorTitles()).toHaveLength(6); + }); + expect(getCreatorTitles()).toEqual([ + 'Creator Epsilon', + 'Creator Beta', + 'Creator Eta', + 'Creator Gamma', + 'Creator Alpha', + 'Creator Zeta', + ]); + + // Assert pagination shows we're back on page 1 + expect(screen.getByText(/page 1 of 2/i)).toBeInTheDocument(); + + // Assert the URL reflects the new sort + await waitFor(() => { + const search = screen.getByTestId('location-search').textContent || ''; + expect(search).toContain('sort=price-asc'); + }); + }); + + it('shows loading state briefly when sort changes mid-list', async () => { + render( + + + + + + ); + + // Wait for initial load + await waitFor(() => expect(mockGetCourses).toHaveBeenCalledTimes(1)); + + // Navigate to page 2 + const nextButton = screen.getByRole('button', { + name: /go to next page/i, + }); + fireEvent.click(nextButton); + + await waitFor(() => { + expect(getCreatorTitles()).toEqual(['Creator Eta']); + }); + + // Change sort — loading state should appear + const sortDropdown = screen.getByLabelText(/^sort$/i); + fireEvent.change(sortDropdown, { target: { value: 'price-asc' } }); + + await waitFor(() => { + expect(screen.getByText(/updating results/i)).toBeInTheDocument(); + }); + + // Loading state should eventually disappear after results load + await waitFor(() => { + expect(screen.queryByText(/updating results/i)).not.toBeInTheDocument(); + }); + }); +}); diff --git a/src/pages/__tests__/LandingPage.tradeConfirmToast.integration.test.tsx b/src/pages/__tests__/LandingPage.tradeConfirmToast.integration.test.tsx new file mode 100644 index 00000000..ff89040f --- /dev/null +++ b/src/pages/__tests__/LandingPage.tradeConfirmToast.integration.test.tsx @@ -0,0 +1,184 @@ +import type { ComponentProps, ReactNode } from 'react'; +import { cleanup, render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { MemoryRouter } from 'react-router'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import LandingPage from '@/pages/LandingPage'; +import { courseService } from '@/services/course.service'; +import showToast from '@/utils/toast.util'; + +vi.mock('@/services/course.service', () => ({ + courseService: { getCourses: vi.fn() }, +})); + +vi.mock('@/utils/toast.util', () => ({ + default: { + message: vi.fn(), + success: vi.fn(), + error: vi.fn(), + loading: vi.fn(), + transactionSuccess: vi.fn(), + }, +})); + +vi.mock('@/hooks/useNetworkMismatch', () => ({ + useNetworkMismatch: () => ({ + isMismatch: false, + expectedChainName: 'Stellar Testnet', + }), +})); + +vi.mock('@/hooks/useStaleData', () => ({ + useStaleData: () => ({ + stale: false, + ageMs: 0, + msUntilStale: 60_000, + revalidate: vi.fn(), + }), +})); + +vi.mock('@/components/common/StellarConnectionQualityBadge', async () => { + const React = await import('react'); + + return { + default: () => React.createElement('div', { role: 'status' }, 'RPC good'), + }; +}); + +vi.mock('@/components/common/CreatorCard', async () => { + const React = await import('react'); + + return { + default: ({ creator }: { creator: { title: string } }) => + React.createElement( + 'article', + { 'aria-label': `Creator ${creator.title}` }, + creator.title + ), + }; +}); + +vi.mock('framer-motion', async () => { + const React = await import('react'); + type MotionDivProps = ComponentProps<'div'> & { + layout?: boolean; + transition?: unknown; + }; + + return { + AnimatePresence: ({ children }: { children: ReactNode }) => + React.createElement(React.Fragment, null, children), + LayoutGroup: ({ children }: { children: ReactNode }) => + React.createElement(React.Fragment, null, children), + motion: { + div: ({ children, ...props }: MotionDivProps) => { + const { layout, transition, ...divProps } = props; + void layout; + void transition; + + return React.createElement('div', divProps, children); + }, + h1: ({ children, ...props }: ComponentProps<'h1'>) => + React.createElement('h1', props, children), + button: ({ children, ...props }: ComponentProps<'button'>) => + React.createElement('button', props, children), + }, + }; +}); + +const mockGetCourses = vi.mocked(courseService.getCourses); +const mockShowToast = vi.mocked(showToast); + +const mockMatchMedia = () => { + Object.defineProperty(window, 'matchMedia', { + writable: true, + value: vi.fn().mockImplementation((query: string) => ({ + matches: false, + media: query, + onchange: null, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + addListener: vi.fn(), + removeListener: vi.fn(), + dispatchEvent: vi.fn(), + })), + }); +}; + +describe('LandingPage trade confirmation toast (#540)', () => { + beforeEach(() => { + mockMatchMedia(); + window.localStorage.clear(); + window.sessionStorage.clear(); + mockGetCourses.mockReset(); + vi.clearAllMocks(); + }); + + afterEach(() => { + cleanup(); + }); + + it('shows a success toast with quantity and creator name after a confirmed buy', async () => { + mockGetCourses.mockResolvedValue([]); + const user = userEvent.setup(); + render( + + + + ); + + const buyButtons = await screen.findAllByRole('button', { + name: 'Buy', + hidden: true, + }); + await user.click(buyButtons[0]); + + const amountInput = await screen.findByTestId('trade-dialog-amount'); + await user.clear(amountInput); + await user.type(amountInput, '5'); + + await user.click(screen.getByTestId('trade-dialog-confirm')); + + await waitFor( + () => + expect(mockShowToast.transactionSuccess).toHaveBeenCalledTimes(1), + { timeout: 3000 } + ); + expect(mockShowToast.transactionSuccess).toHaveBeenCalledWith( + 'Trade confirmed', + expect.stringMatching(/^Bought 5 keys from .+$/) + ); + }); + + it('shows a success toast with quantity and creator name after a confirmed sell', async () => { + mockGetCourses.mockResolvedValue([]); + const user = userEvent.setup(); + render( + + + + ); + + const sellButtons = await screen.findAllByRole('button', { + name: 'Sell', + hidden: true, + }); + await user.click(sellButtons[0]); + + const amountInput = await screen.findByTestId('trade-dialog-amount'); + await user.clear(amountInput); + await user.type(amountInput, '1'); + + await user.click(screen.getByTestId('trade-dialog-confirm')); + + await waitFor( + () => + expect(mockShowToast.transactionSuccess).toHaveBeenCalledTimes(1), + { timeout: 3000 } + ); + expect(mockShowToast.transactionSuccess).toHaveBeenCalledWith( + 'Trade confirmed', + expect.stringMatching(/^Sold 1 key from .+$/) + ); + }); +}); diff --git a/src/pages/__tests__/LandingPage.tradeShortcut.test.tsx b/src/pages/__tests__/LandingPage.tradeShortcut.test.tsx new file mode 100644 index 00000000..106eecf1 --- /dev/null +++ b/src/pages/__tests__/LandingPage.tradeShortcut.test.tsx @@ -0,0 +1,212 @@ +import type { ComponentProps, ReactNode } from 'react'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { MemoryRouter } from 'react-router'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import LandingPage from '@/pages/LandingPage'; +import { courseService, type Course } from '@/services/course.service'; + +vi.mock('@/hooks/useWallet', () => ({ + useTradeMutation: () => ({ mutateAsync: vi.fn(), isPending: false }), + useWalletHoldings: () => ({ data: [] }), +})); + +vi.mock('@/services/course.service', () => ({ + courseService: { + getCourses: vi.fn(), + }, +})); + +vi.mock('@/hooks/useNetworkMismatch', () => ({ + useNetworkMismatch: () => ({ + isMismatch: false, + expectedChainName: 'Stellar Testnet', + }), +})); + +vi.mock('@/hooks/useStaleData', () => ({ + useStaleData: () => ({ + stale: false, + ageMs: 0, + msUntilStale: 60_000, + revalidate: vi.fn(), + }), +})); + +vi.mock('@/components/common/StellarConnectionQualityBadge', async () => { + const React = await import('react'); + + return { + default: () => React.createElement('div', { role: 'status' }, 'RPC good'), + }; +}); + +vi.mock('@/components/common/CreatorCard', async () => { + const React = await import('react'); + + return { + default: ({ creator }: { creator: { title: string } }) => + React.createElement( + 'article', + { 'aria-label': `Creator ${creator.title}` }, + creator.title + ), + }; +}); + +vi.mock('framer-motion', async () => { + const React = await import('react'); + type MotionDivProps = ComponentProps<'div'> & { + layout?: boolean; + transition?: unknown; + }; + + return { + AnimatePresence: ({ children }: { children: ReactNode }) => + React.createElement(React.Fragment, null, children), + LayoutGroup: ({ children }: { children: ReactNode }) => + React.createElement(React.Fragment, null, children), + motion: { + div: ({ children, ...props }: MotionDivProps) => { + const { layout, transition, ...divProps } = props; + void layout; + void transition; + + return React.createElement('div', divProps, children); + }, + button: ({ children, ...props }: ComponentProps<'button'>) => + React.createElement('button', props, children), + }, + }; +}); + +const mockGetCourses = vi.mocked(courseService.getCourses); + +const creatorList: Course[] = [ + { + id: 'alex-rivers', + title: 'Alex Rivers', + description: 'Digital artist', + price: 0.05, + priceStroops: 500_000, + creatorShareSupply: 120, + instructorId: 'arivers', + category: 'Art', + level: 'BEGINNER', + isVerified: true, + }, +]; + +const mockMatchMedia = () => { + Object.defineProperty(window, 'matchMedia', { + writable: true, + value: vi.fn().mockImplementation((query: string) => ({ + matches: false, + media: query, + onchange: null, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + addListener: vi.fn(), + removeListener: vi.fn(), + dispatchEvent: vi.fn(), + })), + }); +}; + +const renderLandingPage = async () => { + render( + + + + ); + await waitFor(() => expect(mockGetCourses).toHaveBeenCalledTimes(1)); +}; + +function pressT() { + const event = new KeyboardEvent('keydown', { + key: 't', + code: 'KeyT', + bubbles: true, + cancelable: true, + }); + fireEvent(window, event); + return event; +} + +describe('LandingPage trade shortcut — form element suppression', () => { + beforeEach(() => { + mockMatchMedia(); + window.localStorage.clear(); + window.sessionStorage.clear(); + mockGetCourses.mockReset(); + mockGetCourses.mockResolvedValue(creatorList); + }); + + it('opens the trade dialog when focus is on the document body', async () => { + await renderLandingPage(); + + const event = pressT(); + + expect(event.defaultPrevented).toBe(true); + expect(await screen.findByRole('dialog')).toBeInTheDocument(); + }); + + it('does not open the trade dialog when focus is on an input element', async () => { + await renderLandingPage(); + + const input = document.createElement('input'); + document.body.appendChild(input); + + fireEvent.keyDown(input, { + key: 't', + code: 'KeyT', + bubbles: true, + cancelable: true, + }); + + await new Promise(resolve => window.setTimeout(resolve, 0)); + + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + + input.remove(); + }); + + it('does not open the trade dialog when focus is on a textarea element', async () => { + await renderLandingPage(); + + const textarea = document.createElement('textarea'); + document.body.appendChild(textarea); + + fireEvent.keyDown(textarea, { + key: 't', + code: 'KeyT', + bubbles: true, + cancelable: true, + }); + + await new Promise(resolve => window.setTimeout(resolve, 0)); + + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + + textarea.remove(); + }); + + it('does not open the trade dialog when focus is on a select element', async () => { + await renderLandingPage(); + + const select = document.createElement('select'); + document.body.appendChild(select); + + fireEvent.keyDown(select, { + key: 't', + code: 'KeyT', + bubbles: true, + cancelable: true, + }); + + await new Promise(resolve => window.setTimeout(resolve, 0)); + + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + + select.remove(); + }); +}); diff --git a/src/pages/__tests__/LandingPage.tradeShortcutUnmount.integration.test.tsx b/src/pages/__tests__/LandingPage.tradeShortcutUnmount.integration.test.tsx new file mode 100644 index 00000000..27df7d7a --- /dev/null +++ b/src/pages/__tests__/LandingPage.tradeShortcutUnmount.integration.test.tsx @@ -0,0 +1,205 @@ +/** + * Integration test for the `T` trade-shortcut keyboard listener being torn + * down when the creator profile page (LandingPage — see the "Issue 554: T + * key opens the trade panel from the creator profile page" comment on its + * keydown effect) unmounts (#654). + * + * The `useEffect` registering the listener already returns a cleanup + * function that calls `window.removeEventListener`, so this test is meant + * to confirm that wiring actually works end-to-end: + * - `T` opens the trade dialog while the page is mounted + * - Unmounting the page (simulating navigating away, e.g. to a creator + * discovery list elsewhere in the app) removes the listener, so `T` + * does nothing afterwards and produces no console errors + * - Mounting the page again re-registers the listener, so `T` opens the + * trade dialog again + */ +import type { ComponentProps, ReactNode } from 'react'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { MemoryRouter } from 'react-router'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import LandingPage from '@/pages/LandingPage'; +import { courseService, type Course } from '@/services/course.service'; + +vi.mock('@/hooks/useWallet', () => ({ + useTradeMutation: () => ({ mutateAsync: vi.fn(), isPending: false }), + useWalletHoldings: () => ({ data: [] }), +})); + +vi.mock('@/services/course.service', () => ({ + courseService: { + getCourses: vi.fn(), + }, +})); + +vi.mock('@/hooks/useNetworkMismatch', () => ({ + useNetworkMismatch: () => ({ + isMismatch: false, + expectedChainName: 'Stellar Testnet', + }), +})); + +vi.mock('@/hooks/useStaleData', () => ({ + useStaleData: () => ({ + stale: false, + ageMs: 0, + msUntilStale: 60_000, + revalidate: vi.fn(), + }), +})); + +vi.mock('@/components/common/StellarConnectionQualityBadge', async () => { + const React = await import('react'); + + return { + default: () => React.createElement('div', { role: 'status' }, 'RPC good'), + }; +}); + +vi.mock('@/components/common/CreatorCard', async () => { + const React = await import('react'); + + return { + default: ({ creator }: { creator: { title: string } }) => + React.createElement( + 'article', + { 'aria-label': `Creator ${creator.title}` }, + creator.title + ), + }; +}); + +vi.mock('framer-motion', async () => { + const React = await import('react'); + type MotionDivProps = ComponentProps<'div'> & { + layout?: boolean; + transition?: unknown; + }; + + return { + AnimatePresence: ({ children }: { children: ReactNode }) => + React.createElement(React.Fragment, null, children), + LayoutGroup: ({ children }: { children: ReactNode }) => + React.createElement(React.Fragment, null, children), + motion: { + div: ({ children, ...props }: MotionDivProps) => { + const { layout, transition, ...divProps } = props; + void layout; + void transition; + + return React.createElement('div', divProps, children); + }, + button: ({ children, ...props }: ComponentProps<'button'>) => + React.createElement('button', props, children), + }, + }; +}); + +const mockGetCourses = vi.mocked(courseService.getCourses); + +const creatorList: Course[] = [ + { + id: 'alex-rivers', + title: 'Alex Rivers', + description: 'Digital artist', + price: 0.05, + priceStroops: 500_000, + creatorShareSupply: 120, + instructorId: 'arivers', + category: 'Art', + level: 'BEGINNER', + isVerified: true, + }, +]; + +const mockMatchMedia = () => { + Object.defineProperty(window, 'matchMedia', { + writable: true, + value: vi.fn().mockImplementation((query: string) => ({ + matches: false, + media: query, + onchange: null, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + addListener: vi.fn(), + removeListener: vi.fn(), + dispatchEvent: vi.fn(), + })), + }); +}; + +/** A stand-in for "the creator discovery list page" the user navigates to. */ +function DiscoveryListPlaceholder() { + return
Creator discovery list
; +} + +function pressT() { + const event = new KeyboardEvent('keydown', { + key: 't', + code: 'KeyT', + bubbles: true, + cancelable: true, + }); + fireEvent(window, event); + return event; +} + +describe('LandingPage trade shortcut — cleanup on unmount (#654)', () => { + beforeEach(() => { + mockMatchMedia(); + window.localStorage.clear(); + window.sessionStorage.clear(); + mockGetCourses.mockReset(); + mockGetCourses.mockResolvedValue(creatorList); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('opens the trade dialog with T, stops responding after unmount, and works again after remount', async () => { + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + // 1. Mount the creator profile page and confirm T opens the trade dialog. + const { unmount } = render( + + + + ); + await waitFor(() => expect(mockGetCourses).toHaveBeenCalledTimes(1)); + + const firstPress = pressT(); + expect(firstPress.defaultPrevented).toBe(true); + expect(await screen.findByRole('dialog')).toBeInTheDocument(); + + // 2. Navigate away: unmount the profile page and mount a stand-in for + // the creator discovery list page in its place. + unmount(); + const { unmount: unmountDiscoveryList } = render(); + expect(screen.getByTestId('discovery-list-placeholder')).toBeInTheDocument(); + + // 3. T should now do nothing -- no dialog, no preventDefault -- because + // the listener registered by the unmounted page was cleaned up. + const secondPress = pressT(); + expect(secondPress.defaultPrevented).toBe(false); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + expect(consoleErrorSpy).not.toHaveBeenCalled(); + + // 4. Navigate back: unmount the discovery list stand-in and mount the + // profile page again -- this re-registers the listener, so T opens + // the trade dialog once more. + unmountDiscoveryList(); + render( + + + + ); + await waitFor(() => expect(mockGetCourses).toHaveBeenCalledTimes(2)); + + const thirdPress = pressT(); + expect(thirdPress.defaultPrevented).toBe(true); + expect(await screen.findByRole('dialog')).toBeInTheDocument(); + + expect(consoleErrorSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/src/pages/__tests__/LandingPage.tradeSubmitDisabled.integration.test.tsx b/src/pages/__tests__/LandingPage.tradeSubmitDisabled.integration.test.tsx new file mode 100644 index 00000000..f2480538 --- /dev/null +++ b/src/pages/__tests__/LandingPage.tradeSubmitDisabled.integration.test.tsx @@ -0,0 +1,355 @@ +/** + * Integration test for trade panel submit button being disabled while a + * transaction is in flight (#622). + * + * While a buy or sell transaction is pending on-chain, the submit button + * should be disabled to prevent duplicate submissions. The test confirms: + * - The submit button is disabled immediately after submission + * - The submit button is re-enabled after the transaction confirms + * - The submit button is re-enabled after the transaction fails + * - No duplicate submission is possible while the button is disabled + */ +import type { ComponentProps, ReactNode } from 'react'; +import { cleanup, render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { MemoryRouter } from 'react-router'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import LandingPage from '@/pages/LandingPage'; +import { courseService } from '@/services/course.service'; + +// --------------------------------------------------------------------------- +// Deferred promise helpers — lets us control when mutateAsync settles so we +// can observe the in-flight disabled state of the confirm button. +// --------------------------------------------------------------------------- +let mutationResolve: ((value: { success: true }) => void) | null = null; +let mutationReject: ((reason: Error) => void) | null = null; + +function createControllableMutation(): Promise<{ success: true }> { + return new Promise<{ success: true }>((resolve, reject) => { + mutationResolve = resolve; + mutationReject = reject; + }); +} + +// --------------------------------------------------------------------------- +// Module mocks — mirror the pattern from other LandingPage integration tests +// --------------------------------------------------------------------------- + +vi.mock('@/hooks/useWallet', () => ({ + useTradeMutation: () => ({ + mutateAsync: vi.fn().mockImplementation(() => createControllableMutation()), + isPending: false, + }), + useWalletHoldings: () => ({ data: [] }), +})); + +vi.mock('@/services/course.service', () => ({ + courseService: { getCourses: vi.fn() }, +})); + +vi.mock('@/utils/toast.util', () => ({ + default: { + message: vi.fn(), + success: vi.fn(), + error: vi.fn(), + loading: vi.fn(), + transactionSuccess: vi.fn(), + }, +})); + +vi.mock('@/hooks/useNetworkMismatch', () => ({ + useNetworkMismatch: () => ({ + isMismatch: false, + expectedChainName: 'Stellar Testnet', + }), +})); + +vi.mock('@/hooks/useStaleData', () => ({ + useStaleData: () => ({ + stale: false, + ageMs: 0, + msUntilStale: 60_000, + revalidate: vi.fn(), + }), +})); + +vi.mock('@/components/common/StellarConnectionQualityBadge', async () => { + const React = await import('react'); + + return { + default: () => React.createElement('div', { role: 'status' }, 'RPC good'), + }; +}); + +vi.mock('@/components/common/CreatorCard', async () => { + const React = await import('react'); + + return { + default: ({ creator }: { creator: { title: string } }) => + React.createElement( + 'article', + { 'aria-label': `Creator ${creator.title}` }, + creator.title + ), + }; +}); + +vi.mock('framer-motion', async () => { + const React = await import('react'); + type MotionDivProps = ComponentProps<'div'> & { + layout?: boolean; + transition?: unknown; + }; + + return { + AnimatePresence: ({ children }: { children: ReactNode }) => + React.createElement(React.Fragment, null, children), + LayoutGroup: ({ children }: { children: ReactNode }) => + React.createElement(React.Fragment, null, children), + motion: { + div: ({ children, ...props }: MotionDivProps) => { + const { layout, transition, ...divProps } = props; + void layout; + void transition; + + return React.createElement('div', divProps, children); + }, + h1: ({ children, ...props }: ComponentProps<'h1'>) => + React.createElement('h1', props, children), + button: ({ children, ...props }: ComponentProps<'button'>) => + React.createElement('button', props, children), + }, + }; +}); + +const mockGetCourses = vi.mocked(courseService.getCourses); + +const mockMatchMedia = () => { + Object.defineProperty(window, 'matchMedia', { + writable: true, + value: vi.fn().mockImplementation((query: string) => ({ + matches: false, + media: query, + onchange: null, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + addListener: vi.fn(), + removeListener: vi.fn(), + dispatchEvent: vi.fn(), + })), + }); +}; + +const singleCreator: Array<{ + id: string; + title: string; + description: string; + price: number; + priceStroops: number; + creatorShareSupply: number; + instructorId: string; + category: string; + level: string; + isVerified: boolean; + thumbnail: string; +}> = [ + { + id: '1', + title: 'Alex Rivers', + description: 'Digital Artist & Illustrator', + price: 0.05, + priceStroops: 500_000, + creatorShareSupply: 120, + instructorId: 'arivers', + category: 'Art', + level: 'BEGINNER', + isVerified: true, + thumbnail: + 'https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?w=400&h=400&fit=crop', + }, +]; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** + * Opens the trade dialog by clicking a Buy button, sets the amount, and + * clicks the confirm button. Returns the confirm button element so tests + * can assert on its disabled state. + */ +async function submitBuyTrade(user: ReturnType, amount: number) { + const buyButtons = await screen.findAllByRole('button', { + name: 'Buy', + }); + await user.click(buyButtons[0]); + + const amountInput = await screen.findByTestId('trade-dialog-amount'); + await user.clear(amountInput); + await user.type(amountInput, String(amount)); + + const confirmButton = screen.getByTestId('trade-dialog-confirm'); + await user.click(confirmButton); + + return confirmButton; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('LandingPage trade submit button disabled while transaction in flight (#622)', () => { + beforeEach(() => { + mockMatchMedia(); + window.localStorage.clear(); + window.sessionStorage.clear(); + mockGetCourses.mockReset(); + vi.clearAllMocks(); + + // Reset deferred promise hooks + mutationResolve = null; + mutationReject = null; + }); + + afterEach(() => { + cleanup(); + }); + + it('disables the submit button immediately after submission while the transaction is pending', async () => { + const user = userEvent.setup(); + mockGetCourses.mockResolvedValue(singleCreator); + + render( + + + + ); + + // Wait for the page to finish loading + await waitFor(() => expect(mockGetCourses).toHaveBeenCalledTimes(1)); + + // Submit a buy trade — the mutation will hang because our mock never resolves + const confirmButton = await submitBuyTrade(user, 5); + + // The confirm button should be disabled while the transaction is in flight + await waitFor(() => { + expect(confirmButton).toBeDisabled(); + }); + }); + + it('re-enables the submit button after the transaction confirms', async () => { + const user = userEvent.setup(); + mockGetCourses.mockResolvedValue(singleCreator); + + render( + + + + ); + + await waitFor(() => expect(mockGetCourses).toHaveBeenCalledTimes(1)); + + // Submit a buy trade — hangs on our mock + const confirmButton = await submitBuyTrade(user, 5); + + // Assert disabled while pending + await waitFor(() => { + expect(confirmButton).toBeDisabled(); + }); + + // Resolve the transaction + expect(mutationResolve).not.toBeNull(); + mutationResolve!({ success: true }); + + // The dialog should close after confirmation, and the Buy button should + // be re-enabled (accessible again for a new trade). + await waitFor( + () => { + expect(screen.queryByRole('dialog')).toBeNull(); + }, + { timeout: 3000 } + ); + + // Confirm a fresh Buy button is not disabled + const buyButtons = screen.getAllByRole('button', { name: 'Buy' }); + expect(buyButtons[0]).not.toBeDisabled(); + }); + + it('re-enables the submit button after the transaction fails', async () => { + const user = userEvent.setup(); + mockGetCourses.mockResolvedValue(singleCreator); + + render( + + + + ); + + await waitFor(() => expect(mockGetCourses).toHaveBeenCalledTimes(1)); + + // Submit a buy trade — hangs on our mock + const confirmButton = await submitBuyTrade(user, 5); + + // Assert disabled while pending + await waitFor(() => { + expect(confirmButton).toBeDisabled(); + }); + + // Reject the transaction + expect(mutationReject).not.toBeNull(); + mutationReject!(new Error('Transaction rejected by network')); + + // The dialog should stay open on failure (close is in the try block), + // and the confirm button should be re-enabled. + await waitFor( + () => { + expect(confirmButton).not.toBeDisabled(); + }, + { timeout: 3000 } + ); + + // The dialog should still be open so the user can retry + expect(screen.getByRole('dialog')).toBeInTheDocument(); + }); + + it('prevents duplicate submission while the button is disabled', async () => { + const user = userEvent.setup(); + mockGetCourses.mockResolvedValue(singleCreator); + + render( + + + + ); + + await waitFor(() => expect(mockGetCourses).toHaveBeenCalledTimes(1)); + + // Submit a buy trade — hangs on our mock + const confirmButton = await submitBuyTrade(user, 5); + + // Assert disabled while pending + await waitFor(() => { + expect(confirmButton).toBeDisabled(); + }); + + // Attempting to click the disabled confirm button via userEvent should + // be silently rejected (userEvent respects the disabled attribute). + // We verify the dialog stays open afterward — no duplicate submission. + await user.click(confirmButton); + + // Dialog should still be open (no duplicate submission triggered) + expect(screen.getByRole('dialog')).toBeInTheDocument(); + + // Now resolve the original transaction + expect(mutationResolve).not.toBeNull(); + mutationResolve!({ success: true }); + + // Dialog should close exactly once after the single submission resolves + await waitFor( + () => { + expect(screen.queryByRole('dialog')).toBeNull(); + }, + { timeout: 3000 } + ); + }); +}); diff --git a/src/pages/__tests__/MarketingPage.integration.test.tsx b/src/pages/__tests__/MarketingPage.integration.test.tsx new file mode 100644 index 00000000..e4199d23 --- /dev/null +++ b/src/pages/__tests__/MarketingPage.integration.test.tsx @@ -0,0 +1,49 @@ +import { render, screen } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import MarketingPage from '@/pages/MarketingPage'; + +describe('MarketingPage integration (#525)', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('renders without console errors or warnings and includes all major sections', () => { + const consoleErrorSpy = vi.spyOn(console, 'error'); + const consoleWarnSpy = vi.spyOn(console, 'warn'); + + render(); + + expect(consoleErrorSpy).not.toHaveBeenCalled(); + expect(consoleWarnSpy).not.toHaveBeenCalled(); + + expect( + screen.getByRole('heading', { name: /access layer/i }) + ).toBeInTheDocument(); + + expect( + screen.getByText( + /AccessLayer is an open source platform built on the Stellar blockchain/i + ) + ).toBeInTheDocument(); + + expect(screen.getByText(/how it works/i)).toBeInTheDocument(); + expect( + screen.getByText( + /You connect your Stellar wallet, browse the marketplace, and buy keys/i + ) + ).toBeInTheDocument(); + + expect(screen.getAllByText(/^built on stellar$/i)).toHaveLength(2); + expect( + screen.getByText( + /AccessLayer is built on the Stellar blockchain using Soroban smart contracts/i + ) + ).toBeInTheDocument(); + + expect(screen.getByText(/join the community/i)).toBeInTheDocument(); + expect(screen.getByRole('link', { name: /github/i })).toBeInTheDocument(); + expect(screen.getByRole('link', { name: /telegram/i })).toBeInTheDocument(); + + expect(screen.getByAltText(/access layer/i)).toBeInTheDocument(); + }); +}); diff --git a/src/pages/__tests__/MarketingPage.test.tsx b/src/pages/__tests__/MarketingPage.test.tsx new file mode 100644 index 00000000..d1bd491a --- /dev/null +++ b/src/pages/__tests__/MarketingPage.test.tsx @@ -0,0 +1,25 @@ +import { render, screen } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; +import MarketingPage from '@/pages/MarketingPage'; + +describe('MarketingPage community links', () => { + it('GitHub link points to the correct URL and opens in a new tab', () => { + render(); + + const githubLink = screen.getByRole('link', { name: /github/i }); + + expect(githubLink).toHaveAttribute('href', 'https://github.com/accesslayerorg'); + expect(githubLink).toHaveAttribute('target', '_blank'); + expect(githubLink).toHaveAttribute('rel', 'noopener noreferrer'); + }); + + it('Telegram link points to the correct URL and opens in a new tab', () => { + render(); + + const telegramLink = screen.getByRole('link', { name: /telegram/i }); + + expect(telegramLink).toHaveAttribute('href', 'https://t.me/c/accesslayerorg/'); + expect(telegramLink).toHaveAttribute('target', '_blank'); + expect(telegramLink).toHaveAttribute('rel', 'noopener noreferrer'); + }); +}); diff --git a/src/pages/__tests__/NotFoundPage.integration.test.tsx b/src/pages/__tests__/NotFoundPage.integration.test.tsx new file mode 100644 index 00000000..62a93a44 --- /dev/null +++ b/src/pages/__tests__/NotFoundPage.integration.test.tsx @@ -0,0 +1,24 @@ +import { render, screen } from '@testing-library/react'; +import { createMemoryRouter, RouterProvider } from 'react-router'; +import { describe, expect, it } from 'vitest'; +import { routes } from '@/routes'; + +describe('NotFoundPage Integration', () => { + it('renders NotFoundPage when navigating to an unknown route', () => { + const router = createMemoryRouter(routes, { + initialEntries: ['/unknown-path-xyz'], + }); + + render(); + + // Assert the NotFoundPage content is rendered + expect( + screen.getByRole('heading', { + name: /this marketplace path is not live yet/i, + }) + ).toBeInTheDocument(); + + // Assert the page title or heading contains a 404 or not found message + expect(screen.getByText(/route not found/i)).toBeInTheDocument(); + }); +}); diff --git a/src/pages/__tests__/creatorInfiniteScroll.test.tsx b/src/pages/__tests__/creatorInfiniteScroll.test.tsx new file mode 100644 index 00000000..d04f8b37 --- /dev/null +++ b/src/pages/__tests__/creatorInfiniteScroll.test.tsx @@ -0,0 +1,225 @@ +import type { ComponentProps, ReactNode } from 'react'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { MemoryRouter } from 'react-router'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import LandingPage from '@/pages/LandingPage'; +import { courseService, type Course } from '@/services/course.service'; + +vi.mock('@/services/course.service', () => ({ + courseService: { getCourses: vi.fn() }, +})); + +function makeQueryClient() { + return new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }); +} + +vi.mock('@/hooks/useNetworkMismatch', () => ({ + useNetworkMismatch: () => ({ + isMismatch: false, + expectedChainName: 'Stellar Testnet', + }), +})); + +vi.mock('@/hooks/useStaleData', () => ({ + useStaleData: () => ({ + stale: false, + ageMs: 0, + msUntilStale: 60_000, + revalidate: vi.fn(), + }), +})); + +vi.mock('@/components/common/StellarConnectionQualityBadge', async () => { + const React = await import('react'); + return { + default: () => React.createElement('div', { role: 'status' }, 'RPC good'), + }; +}); + +vi.mock('@/components/common/CreatorCard', async () => { + const React = await import('react'); + return { + default: ({ creator }: { creator: { title: string } }) => + React.createElement( + 'article', + { 'aria-label': `Creator ${creator.title}` }, + creator.title + ), + }; +}); + +vi.mock('framer-motion', async () => { + const React = await import('react'); + type MotionDivProps = ComponentProps<'div'> & { + layout?: boolean; + transition?: unknown; + }; + + return { + AnimatePresence: ({ children }: { children: ReactNode }) => + React.createElement(React.Fragment, null, children), + LayoutGroup: ({ children }: { children: ReactNode }) => + React.createElement(React.Fragment, null, children), + motion: { + div: ({ children, ...props }: MotionDivProps) => { + const { layout, transition, ...divProps } = props; + void layout; + void transition; + return React.createElement('div', divProps, children); + }, + h1: ({ children, ...props }: ComponentProps<'h1'>) => + React.createElement('h1', props, children), + button: ({ children, ...props }: ComponentProps<'button'>) => + React.createElement('button', props, children), + }, + }; +}); + +const mockGetCourses = vi.mocked(courseService.getCourses); + +function createCreator(id: string, title: string): Course { + return { + id, + title, + description: `Description for ${title}`, + price: 0.1, + priceStroops: 1_000_000, + creatorShareSupply: 100, + instructorId: title.toLowerCase().replace(/\s+/g, '-'), + category: 'Art', + level: 'BEGINNER', + isVerified: true, + }; +} + +const CREATOR_A = createCreator('1', 'Creator A'); +const CREATOR_B = createCreator('2', 'Creator B'); +const CREATOR_C = createCreator('3', 'Creator C'); +const CREATOR_D = createCreator('4', 'Creator D'); +const CREATOR_E = createCreator('5', 'Creator E'); +const CREATOR_F = createCreator('6', 'Creator F'); +const CREATOR_G = createCreator('7', 'Creator G'); + +const ALL_CREATORS = [ + CREATOR_A, + CREATOR_B, + CREATOR_C, + CREATOR_D, + CREATOR_E, + CREATOR_F, + CREATOR_G, +]; + +const mockMatchMedia = () => { + Object.defineProperty(window, 'matchMedia', { + writable: true, + value: vi.fn().mockImplementation((query: string) => ({ + matches: false, + media: query, + onchange: null, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + addListener: vi.fn(), + removeListener: vi.fn(), + dispatchEvent: vi.fn(), + })), + }); +}; + +const getCreatorTitles = () => + screen.getAllByRole('article').map(node => node.textContent); + +describe('Infinite scroll non-duplication (#651)', () => { + beforeEach(() => { + mockMatchMedia(); + window.localStorage.clear(); + window.sessionStorage.clear(); + window.localStorage.setItem('accesslayer.creator-list-mode', 'infinite'); + mockGetCourses.mockReset(); + mockGetCourses.mockResolvedValue(ALL_CREATORS); + }); + + function renderPage() { + return render( + + + + + + ); + } + + it('appends new creators without duplicating existing ones when loading the next page', async () => { + renderPage(); + + await waitFor(() => { + expect(getCreatorTitles()).toEqual([ + 'Creator A', + 'Creator B', + 'Creator C', + 'Creator D', + 'Creator E', + 'Creator F', + ]); + }); + + const loadMoreButton = await screen.findByRole('button', { + name: /load more creators/i, + }); + fireEvent.click(loadMoreButton); + + await waitFor(() => { + expect(getCreatorTitles()).toEqual([ + 'Creator A', + 'Creator B', + 'Creator C', + 'Creator D', + 'Creator E', + 'Creator F', + 'Creator G', + ]); + }); + + const allCards = screen.getAllByRole('article'); + const titles = allCards.map(card => card.textContent); + const uniqueTitles = new Set(titles); + + expect(titles).toHaveLength(7); + expect(uniqueTitles.size).toBe(7); + + expect( + screen.queryByRole('button', { name: /load more creators/i }) + ).not.toBeInTheDocument(); + }); + + it('keeps page 1 creators visible after page 2 loads without clearing previous results', async () => { + renderPage(); + + await waitFor(() => { + expect(getCreatorTitles()).toHaveLength(6); + }); + + expect(getCreatorTitles()).toContain('Creator A'); + expect(getCreatorTitles()).toContain('Creator B'); + expect(getCreatorTitles()).toContain('Creator C'); + + const loadMoreButton = await screen.findByRole('button', { + name: /load more creators/i, + }); + fireEvent.click(loadMoreButton); + + await waitFor(() => { + expect(getCreatorTitles()).toHaveLength(7); + }); + + expect(getCreatorTitles()).toContain('Creator A'); + expect(getCreatorTitles()).toContain('Creator B'); + expect(getCreatorTitles()).toContain('Creator C'); + expect(getCreatorTitles()).toContain('Creator D'); + expect(getCreatorTitles()).toContain('Creator E'); + expect(getCreatorTitles()).toContain('Creator F'); + }); +}); diff --git a/src/pages/__tests__/holderCountCacheInvalidation.test.tsx b/src/pages/__tests__/holderCountCacheInvalidation.test.tsx new file mode 100644 index 00000000..0ac7782b --- /dev/null +++ b/src/pages/__tests__/holderCountCacheInvalidation.test.tsx @@ -0,0 +1,311 @@ +import type { ReactNode } from 'react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { MemoryRouter } from 'react-router'; +import { act, render, screen, waitFor } from '@testing-library/react'; +import fc from 'fast-check'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { FeaturedCreatorAudienceChip } from '@/components/common/FeaturedCreatorAudienceChip'; +import { getFeaturedCreatorKeyHolderCopy } from '@/utils/holderCount.utils'; +import { formatCompactNumber } from '@/utils/numberFormat.utils'; + +// --------------------------------------------------------------------------- +// Module mocks — mirror the pattern from LandingPage.keyboard.test.tsx +// --------------------------------------------------------------------------- + +vi.mock('@/hooks/useNetworkMismatch', () => ({ + useNetworkMismatch: () => ({ + isMismatch: false, + expectedChainName: 'Stellar Testnet', + }), +})); + +vi.mock('@/components/common/StellarConnectionQualityBadge', async () => { + const React = await import('react'); + return { + default: () => React.createElement('div', { role: 'status' }, 'RPC good'), + }; +}); + +vi.mock('framer-motion', async () => { + const React = await import('react'); + return { + AnimatePresence: ({ children }: { children: ReactNode }) => + React.createElement(React.Fragment, null, children), + LayoutGroup: ({ children }: { children: ReactNode }) => + React.createElement(React.Fragment, null, children), + motion: { + div: ({ children, layout, transition, ...props }: Record & { children?: ReactNode }) => { + void layout; + void transition; + return React.createElement('div', props as Record, children); + }, + button: ({ children, ...props }: Record & { children?: ReactNode }) => + React.createElement('button', props as Record, children), + }, + }; +}); + +// --------------------------------------------------------------------------- +// Test constants & helpers +// --------------------------------------------------------------------------- + +const CREATOR_ID = 'test-creator-42'; + +function createWrapper(queryClient: QueryClient) { + return function Wrapper({ children }: { children: ReactNode }) { + return ( + + {children} + + ); + }; +} + +function makeFreshQueryClient() { + return new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); +} + +// --------------------------------------------------------------------------- +// Test suite +// --------------------------------------------------------------------------- + +describe('FeaturedCreatorAudienceChip — holder count cache invalidation', () => { + let queryClient: QueryClient; + let mockFetchHolderCount: ReturnType Promise>>; + + beforeEach(() => { + queryClient = makeFreshQueryClient(); + mockFetchHolderCount = vi.fn<(id: string) => Promise>() + .mockResolvedValue(null); + }); + + afterEach(() => { + queryClient.clear(); + }); + + // ------------------------------------------------------------------------- + // Property 1: Initial render round-trip + // For any seeded integer count, the DOM displays getFeaturedCreatorKeyHolderCopy(count).value + // with zero calls to the mock fetch. + // Validates: Requirements 1.1, 1.2, 5.4 + // ------------------------------------------------------------------------- + it('Property 1 — initial render round-trip: displays seeded count without calling fetch', async () => { + await fc.assert( + fc.asyncProperty(fc.integer({ min: 1, max: 1_000_000 }), async (count) => { + const localClient = makeFreshQueryClient(); + const localMock = vi.fn<(id: string) => Promise>(); + + localClient.setQueryData(['creator', CREATOR_ID, 'holderCount'], count); + + const { unmount } = render( + , + { wrapper: createWrapper(localClient) }, + ); + + const expectedText = getFeaturedCreatorKeyHolderCopy(count).value; + expect(screen.getByText(expectedText)).toBeInTheDocument(); + expect(localMock).not.toHaveBeenCalled(); + + unmount(); + localClient.clear(); + }), + { numRuns: 100 }, + ); + }); + + // ------------------------------------------------------------------------- + // Property 2: Stale-while-revalidate display stability + // While a pending refetch has not yet resolved, the old value remains visible. + // Validates: Requirement 2.3 + // ------------------------------------------------------------------------- + it('Property 2 — stale-while-revalidate: old value stays visible while refetch is in-flight', async () => { + await fc.assert( + fc.asyncProperty(fc.integer({ min: 1, max: 1_000_000 }), async (initialCount) => { + const localClient = makeFreshQueryClient(); + // A fetch that never resolves during this test window + const neverResolvingFetch = vi.fn<(id: string) => Promise>( + () => new Promise(() => { /* intentionally never resolves */ }), + ); + + localClient.setQueryData(['creator', CREATOR_ID, 'holderCount'], initialCount); + + const { unmount } = render( + , + { wrapper: createWrapper(localClient) }, + ); + + // Trigger invalidation without awaiting — the refetch never resolves + // so awaiting would hang the test. We only verify the stale value + // stays visible while the refetch is in-flight. + localClient.invalidateQueries({ + queryKey: ['creator', CREATOR_ID, 'holderCount'], + }); + + // Old value must still be visible (stale-while-revalidate) + const oldText = getFeaturedCreatorKeyHolderCopy(initialCount).value; + expect(screen.getByText(oldText)).toBeInTheDocument(); + // No error or blank state + expect(screen.queryByText('Key holders unavailable')).not.toBeInTheDocument(); + + unmount(); + localClient.clear(); + }), + { numRuns: 50 }, + ); + }); + + // ------------------------------------------------------------------------- + // Property 3: Post-invalidation update round-trip + // After invalidation + resolved refetch, updated count is shown; old is gone; + // no page reload; same component instance. + // Validates: Requirements 3.1, 3.2, 3.3, 3.4 + // ------------------------------------------------------------------------- + it('Property 3 — post-invalidation update: new count shown, old gone, no page reload', async () => { + await fc.assert( + fc.asyncProperty( + fc.integer({ min: 1, max: 999 }), + fc.integer({ min: 1000, max: 1_000_000 }), + async (initialCount, updatedCount) => { + const localClient = makeFreshQueryClient(); + const localMock = vi.fn<(id: string) => Promise>() + .mockResolvedValue(updatedCount); + + localClient.setQueryData(['creator', CREATOR_ID, 'holderCount'], initialCount); + + const { unmount } = render( + , + { wrapper: createWrapper(localClient) }, + ); + + // Invalidate and allow refetch to resolve + await act(async () => { + await localClient.invalidateQueries({ + queryKey: ['creator', CREATOR_ID, 'holderCount'], + }); + }); + + const updatedText = getFeaturedCreatorKeyHolderCopy(updatedCount).value; + const initialText = getFeaturedCreatorKeyHolderCopy(initialCount).value; + + await waitFor(() => { + expect(screen.getByText(updatedText)).toBeInTheDocument(); + }); + + expect(screen.queryByText(initialText)).not.toBeInTheDocument(); + + unmount(); + localClient.clear(); + }, + ), + { numRuns: 100 }, + ); + }); + + // ------------------------------------------------------------------------- + // Property 4: Format function round-trip (pure function — no render needed) + // For any positive integer n, getFeaturedCreatorKeyHolderCopy(n).value === + // formatCompactNumber(n) + ' key holders' + // Validates: Requirements 5.1, 5.4 + // ------------------------------------------------------------------------- + it('Property 4 — format round-trip: getFeaturedCreatorKeyHolderCopy is consistent with formatCompactNumber', () => { + fc.assert( + fc.property(fc.integer({ min: 1, max: 10_000_000 }), (n) => { + const { value } = getFeaturedCreatorKeyHolderCopy(n); + expect(value).toBe(`${formatCompactNumber(n)} key holders`); + }), + { numRuns: 200 }, + ); + }); + + // ------------------------------------------------------------------------- + // Edge cases + // ------------------------------------------------------------------------- + + it('edge case — count = 0: renders "No key holders yet"', () => { + queryClient.setQueryData(['creator', CREATOR_ID, 'holderCount'], 0); + + render( + , + { wrapper: createWrapper(queryClient) }, + ); + + expect(screen.getByText('No key holders yet')).toBeInTheDocument(); + }); + + it('edge case — count = null: renders "Key holders unavailable"', () => { + queryClient.setQueryData(['creator', CREATOR_ID, 'holderCount'], null); + + render( + , + { wrapper: createWrapper(queryClient) }, + ); + + expect(screen.getByText('Key holders unavailable')).toBeInTheDocument(); + }); + + it('edge case — non-matching query key: invalidation does not call mockFetch', async () => { + queryClient.setQueryData(['creator', CREATOR_ID, 'holderCount'], 42); + + render( + , + { wrapper: createWrapper(queryClient) }, + ); + + await act(async () => { + await queryClient.invalidateQueries({ + queryKey: ['creator', 'different-creator-id', 'holderCount'], + }); + }); + + expect(mockFetchHolderCount).not.toHaveBeenCalled(); + // Display unchanged + expect(screen.getByText(getFeaturedCreatorKeyHolderCopy(42).value)).toBeInTheDocument(); + }); + + it('edge case — after invalidation: mockFetch called exactly once with CREATOR_ID', async () => { + const updatedCount = 99; + mockFetchHolderCount.mockResolvedValue(updatedCount); + queryClient.setQueryData(['creator', CREATOR_ID, 'holderCount'], 42); + + 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(mockFetchHolderCount).toHaveBeenCalledTimes(1); + expect(mockFetchHolderCount).toHaveBeenCalledWith(CREATOR_ID); + }); +}); diff --git a/src/providers/Web3Provider.tsx b/src/providers/Web3Provider.tsx index 0c4b1499..1d3ba5c3 100644 --- a/src/providers/Web3Provider.tsx +++ b/src/providers/Web3Provider.tsx @@ -1,22 +1,31 @@ import type { ReactNode } from 'react'; -import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { QueryClientProvider } from '@tanstack/react-query'; import { WagmiProvider } from 'wagmi'; import { wagmiConfig } from '@/lib/web3/wagmiConfig'; - -const queryClient = new QueryClient(); +import { useWalletConnectionLogger } from '@/hooks/useWalletConnectionLogger'; +import { queryClient } from './web3Utils'; interface Web3ProviderProps { - children: ReactNode; + children: ReactNode; } -function Web3Provider({ children }: Web3ProviderProps) { - return ( - - - {children} - - - ); +/** + * Mounted once at the Web3 provider boundary so wallet connections are + * logged exactly once app-wide, regardless of which UI (connect button, + * reconnect banner, etc.) initiated the connection. + */ +function WalletConnectionLogger() { + useWalletConnectionLogger(); + return null; } -export default Web3Provider; +export default function Web3Provider({ children }: Web3ProviderProps) { + return ( + + + + {children} + + + ); +} diff --git a/src/providers/__tests__/Web3Provider.test.tsx b/src/providers/__tests__/Web3Provider.test.tsx new file mode 100644 index 00000000..e23636f2 --- /dev/null +++ b/src/providers/__tests__/Web3Provider.test.tsx @@ -0,0 +1,51 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest'; +import { queryClient } from '@/providers/Web3Provider'; + +describe('QueryClient MutationCache Error Logger', () => { + let consoleDebugSpy: ReturnType; + + beforeEach(() => { + consoleDebugSpy = vi.spyOn(console, 'debug').mockImplementation(() => {}); + }); + + it('emits structured log on final failure and filters sensitive fields', () => { + const mutationCache = queryClient.getMutationCache(); + const onError = mutationCache.config.onError; + + expect(onError).toBeDefined(); + + const stellarAddress = 'GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5'; + const ethAddress = '0x71C7656EC7ab88b098defB751B7401B5f6d8976F'; + + const error = { + status: 400, + message: `Failed for ${stellarAddress} and wallet ${ethAddress}`, + }; + + const variables = { + creator_id: 'creator-123', + amount: 50.5, + price: '10.0', + walletAddress: ethAddress, + }; + + const mutation = { options: { mutationKey: ['submitTrade', ethAddress, '100'] } } as unknown; + + onError!(error, variables, {}, mutation); + + expect(consoleDebugSpy).toHaveBeenCalledTimes(1); + const logArgs = consoleDebugSpy.mock.calls[0][1]; + + // All 4 fields must be present + expect(logArgs).toHaveProperty('mutation_key'); + expect(logArgs).toHaveProperty('status_code', 400); + expect(logArgs).toHaveProperty('error_message'); + expect(logArgs).toHaveProperty('creator_id', 'creator-123'); + + // Sensitive fields must not appear in the log + expect(JSON.stringify(logArgs)).not.toContain(stellarAddress); + expect(JSON.stringify(logArgs)).not.toContain(ethAddress); + expect(JSON.stringify(logArgs)).not.toContain('50.5'); + expect(JSON.stringify(logArgs)).not.toContain('100'); + }); +}); diff --git a/src/providers/web3Utils.ts b/src/providers/web3Utils.ts new file mode 100644 index 00000000..86f41096 --- /dev/null +++ b/src/providers/web3Utils.ts @@ -0,0 +1,81 @@ +import { QueryClient, MutationCache } from '@tanstack/react-query'; + +/** + * Sanitizes values for logging, redacting sensitive information. + * Returns the sanitized value as `unknown`. + */ +export function sanitizeValue(val: unknown): unknown { + if (typeof val === 'string') { + const stellarRegex = /\b[G][A-D2-7][A-Z2-7]{54}\b/g; + const ethRegex = /\b0x[a-fA-F0-9]{40}\b/g; + let sanitized = val.replace(stellarRegex, '[REDACTED_ADDRESS]'); + sanitized = sanitized.replace(ethRegex, '[REDACTED_ADDRESS]'); + if (/^\d+(?:\.\d+)?$/.test(sanitized)) { + return '[REDACTED]'; + } + // Redact numbers that are not standard HTTP status codes and not part of alphanumeric identifiers + sanitized = sanitized.replace(/(? = {}; + for (const key of Object.keys(val)) { + if (/address|wallet|amount|price|value|stroops/i.test(key)) { + result[key] = '[REDACTED]'; + } else { + result[key] = sanitizeValue((val as Record)[key]); + } + } + return result; + } + return val; +} + +/** Shared QueryClient with MutationCache that logs structured errors */ +export const queryClient = new QueryClient({ + mutationCache: new MutationCache({ + onError: (error, variables, _context, mutation) => { + const rawKey = (mutation as { options?: { mutationKey?: unknown } }).options?.mutationKey; + const mutation_key = Array.isArray(rawKey) + ? (rawKey as unknown[]).map(sanitizeValue) + : sanitizeValue(rawKey); + + let status_code: number | undefined; + if (error && typeof error === 'object') { + const err = error as { status?: number; statusCode?: number; response?: { status?: number } }; + status_code = err.status ?? err.statusCode ?? err.response?.status; + } + + let error_message: unknown = ''; + if (error instanceof Error) { + error_message = error.message; + } else if (error && typeof error === 'object') { + error_message = (error as { message?: string }).message ?? JSON.stringify(error); + } else { + error_message = String(error); + } + error_message = sanitizeValue(error_message); + + let creator_id: string | undefined; + if (variables && typeof variables === 'object') { + const rawCreatorId = (variables as { creator_id?: unknown; creatorId?: unknown }).creator_id ?? (variables as { creatorId?: unknown }).creatorId; + if (rawCreatorId !== undefined) { + creator_id = sanitizeValue(String(rawCreatorId)) as unknown as string; + } + } + + console.debug('[mutation-failure]', { + mutation_key, + status_code, + error_message, + creator_id, + }); + }, + }), +}); diff --git a/src/routes.tsx b/src/routes.tsx new file mode 100644 index 00000000..b4add511 --- /dev/null +++ b/src/routes.tsx @@ -0,0 +1,31 @@ +import HomePage from './pages/HomePage'; +import NotFoundPage from './pages/NotFoundPage'; +import CreatorDetailPage from './pages/CreatorDetailPage'; +import NotificationsPage from './pages/NotificationsPage'; + +export const routes = [ + { + path: '/', + element: , + }, + { + path: '/creators', + element: , + }, + { + path: '/creator/:id', + element: , + }, + { + path: '/creators/:id', + element: , + }, + { + path: '/notifications', + element: , + }, + { + path: '*', + element: , + }, +]; diff --git a/src/services/course.service.ts b/src/services/course.service.ts index e2abd98a..460b3ca4 100644 --- a/src/services/course.service.ts +++ b/src/services/course.service.ts @@ -23,13 +23,42 @@ export interface Course { joinedAt?: string; /** Whether this creator is pinned in the marketplace list. */ isPinned?: boolean; + creatorFeeBps?: number; + protocolFeeBps?: number; + /** Last up to 7 price history points in stroops, oldest to newest. */ + priceHistory?: number[]; } +export type CourseSortOption = + | 'featured' + | 'price-asc' + | 'price-desc' + | 'supply-desc'; + export interface GetCoursesParams { page?: number; limit?: number; category?: string; search?: string; + min_price?: number; + max_price?: number; + sort?: Exclude; +} + +/** Raw envelope shape for a paginated /courses response. */ +interface CoursesPageEnvelope { + items?: Course[]; + data?: Course[]; + has_more?: boolean; + hasMore?: boolean; +} + +export interface CoursesPage { + items: Course[]; + /** The page number that was requested (used as this page's cursor). */ + page: number; + /** Whether another page is available after this one. */ + hasMore: boolean; } class CourseService extends BaseApiService { @@ -55,6 +84,46 @@ class CourseService extends BaseApiService { } } + /** + * Get one cursor-paginated page of courses for infinite-scroll marketplace + * browsing - GET /courses (#685). `page` is used as the cursor: pass the + * previous response's `page + 1` to fetch the next page. + * + * `hasMore` is read from the response's `has_more`/`hasMore` field when + * the backend provides it, falling back to "this page was full" (item + * count equals the requested limit) when it doesn't -- a full page means + * there could be more, an under-full page means we've reached the end. + */ + async getCoursesPage( + page: number, + params?: Omit + ): Promise { + const limit = params?.limit ?? 20; + const requestParams: GetCoursesParams = { ...params, page, limit }; + const cacheKey = `courses_page_${JSON.stringify(requestParams)}`; + const cached = cacheManager.get(cacheKey); + if (cached) return cached; + + try { + const response = await this.api.get>( + '/courses', + { params: requestParams } + ); + + const raw = response.data.data; + const items: Course[] = Array.isArray(raw) ? raw : (raw.items ?? raw.data ?? []); + const hasMore: boolean = Array.isArray(raw) + ? items.length === limit + : (raw.has_more ?? raw.hasMore ?? items.length === limit); + + const result: CoursesPage = { items, page, hasMore }; + cacheManager.set(cacheKey, result, this.PROFILE_CACHE_TTL); + return result; + } catch (error) { + throw this.handleError(error); + } + } + // Get single course - GET /courses/:id async getCourse(courseId: string): Promise { const cacheKey = `course_${courseId}`; diff --git a/src/services/creatorActivity.service.ts b/src/services/creatorActivity.service.ts new file mode 100644 index 00000000..7b30ffc8 --- /dev/null +++ b/src/services/creatorActivity.service.ts @@ -0,0 +1,34 @@ +// src/services/creatorActivity.service.ts +import { BaseApiService, type APIResponse } from './api.service'; + +/** + * A single trade entry on a creator's public activity feed. + * + * Mirrors the shape consumed by the existing `TransactionHistory` + * component so the feed can pass entries straight through. + */ +export interface CreatorActivityTrade { + id: string; + type: 'buy' | 'sell'; + traderHandle: string; + amount: number; + price: number; + timestamp: number; + txHash: string; + status: 'completed' | 'pending' | 'failed'; +} + +class CreatorActivityService extends BaseApiService { + async getCreatorActivity(creatorId: string): Promise { + try { + const response = await this.api.get>( + `/creators/${creatorId}/activity` + ); + return response.data.data; + } catch (error) { + throw this.handleError(error); + } + } +} + +export const creatorActivityService = new CreatorActivityService(); diff --git a/src/services/notification.service.ts b/src/services/notification.service.ts new file mode 100644 index 00000000..c3f8398b --- /dev/null +++ b/src/services/notification.service.ts @@ -0,0 +1,48 @@ +// src/services/notification.service.ts +import { BaseApiService, type APIResponse } from './api.service'; + +/** The kind of event that generated a notification. */ +export type NotificationType = 'new_follower' | 'key_purchase' | 'price_milestone'; + +/** A single notification entry returned from the API. */ +export interface Notification { + id: string; + type: NotificationType; + message: string; + /** ISO 8601 timestamp when the notification was created. */ + createdAt: string; + read: boolean; + /** Client-side path to navigate to when this notification is clicked. */ + href: string; +} + +/** Shape of the API response for a notifications list request. */ +export interface NotificationsResponse { + notifications: Notification[]; + unreadCount: number; +} + +class NotificationService extends BaseApiService { + /** Fetch the current user's notifications — GET /notifications */ + async getNotifications(userId: string): Promise { + try { + const response = await this.api.get< + APIResponse + >(`/notifications`, { params: { userId } }); + return response.data.data; + } catch (error) { + throw this.handleError(error); + } + } + + /** Mark a single notification as read — PATCH /notifications/:id/read */ + async markAsRead(notificationId: string): Promise { + try { + await this.api.patch(`/notifications/${notificationId}/read`); + } catch (error) { + throw this.handleError(error); + } + } +} + +export const notificationService = new NotificationService(); diff --git a/src/services/walletActivity.service.ts b/src/services/walletActivity.service.ts new file mode 100644 index 00000000..ea720048 --- /dev/null +++ b/src/services/walletActivity.service.ts @@ -0,0 +1,82 @@ +// src/services/walletActivity.service.ts +import { BaseApiService, type APIResponse } from './api.service'; +import { cacheManager } from '@/utils/cache.utils'; + +/** + * Single wallet trade entry returned from the activity feed. + * + * Mirrors the shape consumed by the existing `TransactionHistory` + * component so the feed can pass entries straight through. + */ +export interface WalletActivityTrade { + id: string; + type: 'buy' | 'sell'; + /** 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; + txHash: string; + status: 'completed' | 'pending' | 'failed'; +} + +export interface WalletActivityPage { + trades: WalletActivityTrade[]; + /** + * The next page token. Returning `null` signals "no more pages" + * which stops the infinite query from refetching. + */ + nextPage: number | null; +} + +export interface GetWalletActivityParams { + address: string; + /** 1-indexed page number. */ + page: number; + /** Page size; defaults to the backend's first-page count. */ + limit?: number; +} + +const ACTIVITY_CACHE_PREFIX = 'wallet_activity_'; +const ACTIVITY_PAGE_TTL_MS = 15_000; + +class WalletActivityService extends BaseApiService { + async getWalletActivity({ + address, + page, + limit, + }: GetWalletActivityParams): Promise { + const cacheKey = `${ACTIVITY_CACHE_PREFIX}${address}_${page}_${limit ?? 'default'}`; + const cached = cacheManager.get(cacheKey); + if (cached) return cached; + + try { + const response = await this.api.get>( + `/wallet/${address}/activity`, + { params: { page, ...(limit ? { limit } : {}) } } + ); + + const data = response.data.data; + cacheManager.set(cacheKey, data, ACTIVITY_PAGE_TTL_MS); + return data; + } catch (error) { + throw this.handleError(error); + } + } +} + +export const walletActivityService = new WalletActivityService(); + +/** + * Convenience wrapper exposing the service call as a plain function so + * it can be swapped via `vi.spyOn` from integration tests without needing + * to mock the service class instance itself. + */ +export async function fetchWalletActivityPage( + address: string, + page: number +): Promise { + return walletActivityService.getWalletActivity({ address, page }); +} diff --git a/src/utils/__tests__/activityTimeline.utils.test.ts b/src/utils/__tests__/activityTimeline.utils.test.ts new file mode 100644 index 00000000..7febfce5 --- /dev/null +++ b/src/utils/__tests__/activityTimeline.utils.test.ts @@ -0,0 +1,29 @@ +import { formatActivityAmount } from '@/utils/activityTimeline.utils'; +import { formatKeyPrice } from '@/utils/keyPriceDisplay.utils'; + +describe('formatActivityAmount', () => { + it('formats a buy amount with a negative sign', () => { + const amount = 10_000_000n; // 1 XLM + expect(formatActivityAmount(amount, 'buy')).toBe(`-${formatKeyPrice(amount)}`); + }); + + it('formats a sell amount with a positive sign', () => { + const amount = 10_000_000n; // 1 XLM + expect(formatActivityAmount(amount, 'sell')).toBe(`+${formatKeyPrice(amount)}`); + }); + + it('formats a zero amount with a positive sign', () => { + const amount = 0n; + expect(formatActivityAmount(amount, 'sell')).toBe('+0.00 XLM'); + }); + + it('formats a zero amount for buy type with a positive sign', () => { + const amount = 0n; + expect(formatActivityAmount(amount, 'buy')).toBe('+0.00 XLM'); + }); + + it('formats a small amount with 4 decimal places', () => { + const amount = 5_000_000n; // 0.5 XLM + expect(formatActivityAmount(amount, 'sell')).toBe(`+${formatKeyPrice(amount)}`); + }); +}); diff --git a/src/utils/__tests__/bondingCurve.utils.test.ts b/src/utils/__tests__/bondingCurve.utils.test.ts new file mode 100644 index 00000000..be9f444c --- /dev/null +++ b/src/utils/__tests__/bondingCurve.utils.test.ts @@ -0,0 +1,241 @@ +import { describe, expect, it } from 'vitest'; +import { + computeBondingCurvePrice, + computeBondingCurvePriceXLM, + computeBuyCost, + computeSellRevenue, + DEFAULT_BONDING_CURVE_PARAMS, + type BondingCurveParams, +} from '../bondingCurve.utils'; + +describe('bonding curve utilities', () => { + const defaultParams: BondingCurveParams = { + basePriceStroops: 10_000_000, // 1 XLM + growthFactor: 1.01, // 1% growth per key + }; + + describe('computeBondingCurvePrice', () => { + it('returns base price when supply is 0', () => { + const price = computeBondingCurvePrice(0, defaultParams); + expect(price).toBe(10_000_000); + }); + + it('increases price as supply increases', () => { + const price0 = computeBondingCurvePrice(0, defaultParams); + const price10 = computeBondingCurvePrice(10, defaultParams); + const price100 = computeBondingCurvePrice(100, defaultParams); + + expect(price10).toBeGreaterThan(price0); + expect(price100).toBeGreaterThan(price10); + }); + + it('calculates correct price for linear bonding curve', () => { + // At supply 10: price = 10_000_000 * (1 + 0.01 * 10) = 10_000_000 * 1.1 = 11_000_000 + const price = computeBondingCurvePrice(10, defaultParams); + expect(price).toBe(11_000_000); + }); + + it('handles fractional growth factors', () => { + const params: BondingCurveParams = { + basePriceStroops: 5_000_000, + growthFactor: 1.005, // 0.5% growth + }; + const price = computeBondingCurvePrice(20, params); + // price = 5_000_000 * (1 + 0.005 * 20) = 5_000_000 * 1.1 = 5_500_000 + expect(price).toBeCloseTo(5_500_000); + }); + + it('throws error for negative supply', () => { + expect(() => computeBondingCurvePrice(-1, defaultParams)).toThrow( + 'Supply cannot be negative' + ); + }); + + it('throws error for negative base price', () => { + const invalidParams: BondingCurveParams = { + basePriceStroops: -100, + growthFactor: 1.01, + }; + expect(() => computeBondingCurvePrice(10, invalidParams)).toThrow( + 'Base price cannot be negative' + ); + }); + + it('throws error for non-positive growth factor', () => { + const invalidParams: BondingCurveParams = { + basePriceStroops: 10_000_000, + growthFactor: 0, + }; + expect(() => computeBondingCurvePrice(10, invalidParams)).toThrow( + 'Growth factor must be positive' + ); + }); + + it('handles zero growth factor (flat curve)', () => { + const flatParams: BondingCurveParams = { + basePriceStroops: 10_000_000, + growthFactor: 1.0, // No growth + }; + const price = computeBondingCurvePrice(100, flatParams); + expect(price).toBe(10_000_000); // Price stays constant + }); + }); + + describe('computeBondingCurvePriceXLM', () => { + it('converts stroops to XLM correctly', () => { + const priceXLM = computeBondingCurvePriceXLM(0, defaultParams); + expect(priceXLM).toBe(1); // 10_000_000 stroops = 1 XLM + }); + + it('returns decimal XLM values', () => { + const priceXLM = computeBondingCurvePriceXLM(10, defaultParams); + // 11_000_000 stroops = 1.1 XLM + expect(priceXLM).toBe(1.1); + }); + + it('handles small stroop amounts', () => { + const smallParams: BondingCurveParams = { + basePriceStroops: 1_000_000, // 0.1 XLM + growthFactor: 1.01, + }; + const priceXLM = computeBondingCurvePriceXLM(0, smallParams); + expect(priceXLM).toBe(0.1); + }); + }); + + describe('computeBuyCost', () => { + it('calculates cost for single key at base price', () => { + const cost = computeBuyCost(0, 1, defaultParams); + // avg price between supply 0 and 1: (10_000_000 + 10_100_000) / 2 = 10_050_000 + expect(cost).toBeCloseTo(10_050_000); + }); + + it('calculates cost for multiple keys', () => { + const cost = computeBuyCost(0, 10, defaultParams); + // Average price between supply 0 and 10: (10_000_000 + 11_000_000) / 2 = 10_500_000 + // Total cost: 10_500_000 * 10 = 105_000_000 + expect(cost).toBe(105_000_000); + }); + + it('calculates cost starting from non-zero supply', () => { + const cost = computeBuyCost(10, 5, defaultParams); + // Price at supply 10: 11_000_000 + // Price at supply 15: 10_000_000 * (1 + 0.01 * 15) = 11_500_000 + // Average: (11_000_000 + 11_500_000) / 2 = 11_250_000 + // Total: 11_250_000 * 5 = 56_250_000 + expect(cost).toBe(56_250_000); + }); + + it('throws error for negative quantity', () => { + expect(() => computeBuyCost(0, -1, defaultParams)).toThrow( + 'Quantity cannot be negative' + ); + }); + + it('throws error for negative current supply', () => { + expect(() => computeBuyCost(-1, 1, defaultParams)).toThrow( + 'Current supply cannot be negative' + ); + }); + + it('handles zero quantity', () => { + const cost = computeBuyCost(10, 0, defaultParams); + expect(cost).toBe(0); + }); + }); + + describe('computeSellRevenue', () => { + it('calculates revenue for single key sale', () => { + const revenue = computeSellRevenue(1, 1, defaultParams); + // Price at supply 0: 10_000_000 + // Price at supply 1: 10_100_000 + // Average: (10_000_000 + 10_100_000) / 2 = 10_050_000 + expect(revenue).toBe(10_050_000); + }); + + it('calculates revenue for multiple keys', () => { + const revenue = computeSellRevenue(10, 5, defaultParams); + // Price at supply 5: 10_500_000 + // Price at supply 10: 11_000_000 + // Average: (10_500_000 + 11_000_000) / 2 = 10_750_000 + // Total: 10_750_000 * 5 = 53_750_000 + expect(revenue).toBe(53_750_000); + }); + + it('throws error when selling more than current supply', () => { + expect(() => computeSellRevenue(5, 10, defaultParams)).toThrow( + 'Cannot sell more keys than current supply' + ); + }); + + it('throws error for negative quantity', () => { + expect(() => computeSellRevenue(10, -1, defaultParams)).toThrow( + 'Quantity cannot be negative' + ); + }); + + it('handles selling entire supply', () => { + const revenue = computeSellRevenue(10, 10, defaultParams); + // Price at supply 0: 10_000_000 + // Price at supply 10: 11_000_000 + // Average: (10_000_000 + 11_000_000) / 2 = 10_500_000 + // Total: 10_500_000 * 10 = 105_000_000 + expect(revenue).toBe(105_000_000); + }); + + it('handles zero quantity', () => { + const revenue = computeSellRevenue(10, 0, defaultParams); + expect(revenue).toBe(0); + }); + }); + + describe('DEFAULT_BONDING_CURVE_PARAMS', () => { + it('has valid default parameters', () => { + expect(DEFAULT_BONDING_CURVE_PARAMS.basePriceStroops).toBe(10_000_000); + expect(DEFAULT_BONDING_CURVE_PARAMS.growthFactor).toBe(1.01); + }); + + it('can be used with computeBondingCurvePrice', () => { + const price = computeBondingCurvePrice(10, DEFAULT_BONDING_CURVE_PARAMS); + expect(price).toBe(11_000_000); + }); + }); + + describe('integration scenarios', () => { + it('buy and sell are inverse operations (ignoring slippage)', () => { + const initialSupply = 10; + const buyQuantity = 5; + + const buyCost = computeBuyCost(initialSupply, buyQuantity, defaultParams); + const newSupply = initialSupply + buyQuantity; + const sellRevenue = computeSellRevenue(newSupply, buyQuantity, defaultParams); + + // Due to linear curve, buy cost should equal sell revenue for same quantity + expect(sellRevenue).toBe(buyCost); + }); + + it('calculates price progression across supply range', () => { + const prices = []; + for (let i = 0; i <= 100; i += 10) { + prices.push(computeBondingCurvePrice(i, defaultParams)); + } + + // Verify monotonic increase + for (let i = 1; i < prices.length; i++) { + expect(prices[i]).toBeGreaterThan(prices[i - 1]); + } + + // Verify specific values + expect(prices[0]).toBeCloseTo(10_000_000); // Supply 0 + expect(prices[5]).toBeCloseTo(15_000_000); // Supply 50 (10M * (1 + 0.01*50)) + expect(prices[10]).toBeCloseTo(20_000_000); // Supply 100 (10M * (1 + 0.01*100)) + }); + + it('handles large supply values', () => { + const largeSupply = 10000; + const price = computeBondingCurvePrice(largeSupply, defaultParams); + // price = 10_000_000 * (1 + 0.01 * 10000) = 10_000_000 * 101 = 1_010_000_000 + expect(price).toBeCloseTo(1_010_000_000); + }); + }); +}); diff --git a/src/utils/__tests__/creatorListKey.utils.test.ts b/src/utils/__tests__/creatorListKey.utils.test.ts new file mode 100644 index 00000000..b21242de --- /dev/null +++ b/src/utils/__tests__/creatorListKey.utils.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from 'vitest'; +import { creatorListKey } from '@/utils/creatorListKey.utils'; + +describe('creatorListKey', () => { + it.each([ + [1, 'creator-1'], + [42, 'creator-42'], + [9_999, 'creator-9999'], + ['alpha', 'creator-alpha'], + ['creator-beta', 'creator-beta'], + ])('returns a stable creator key for creator id %s', (creatorId, key) => { + expect(creatorListKey(creatorId)).toBe(key); + }); +}); diff --git a/src/utils/__tests__/globalErrorHandler.utils.test.ts b/src/utils/__tests__/globalErrorHandler.utils.test.ts new file mode 100644 index 00000000..3f54cec2 --- /dev/null +++ b/src/utils/__tests__/globalErrorHandler.utils.test.ts @@ -0,0 +1,223 @@ +import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'; +import { + initGlobalErrorHandler, + handleGlobalError, + markErrorAsCaught, + wasErrorAlreadyCaught, + emitStructuredLog, + resetErrorTracking, +} from '@/utils/globalErrorHandler.utils'; + +describe('Global Error Handler', () => { + beforeEach(() => { + resetErrorTracking(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('initGlobalErrorHandler', () => { + it('sets window.onerror to handleGlobalError', () => { + initGlobalErrorHandler(); + expect(window.onerror).toBe(handleGlobalError); + }); + + it('does not throw when called multiple times', () => { + initGlobalErrorHandler(); + expect(() => initGlobalErrorHandler()).not.toThrow(); + expect(window.onerror).toBe(handleGlobalError); + }); + }); + + describe('handleGlobalError', () => { + it('does not emit a structured log for errors already caught by error boundary', () => { + const error = new Error('Render error in creator list'); + markErrorAsCaught(error); + + const logSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + const result = handleGlobalError('Render error in creator list', 'LandingPage.tsx', 852, 10, error); + + expect(logSpy).not.toHaveBeenCalled(); + expect(result).toBe(true); // Prevent default browser handling + }); + + it('emits a structured log for unhandled errors outside React', () => { + const error = new Error('Timeout error in setTimeout callback'); + const logSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + const result = handleGlobalError('Timeout error in setTimeout callback', 'app.js', 15, 3, error); + + expect(logSpy).toHaveBeenCalledWith('[Global Error Handler]', { + message: 'Timeout error in setTimeout callback', + source: 'app.js', + line: 15, + column: 3, + error: { + name: 'Error', + message: 'Timeout error in setTimeout callback', + stack: expect.any(String), + }, + timestamp: expect.any(Number), + }); + expect(result).toBe(false); // Allow default browser handling + }); + + it('does not emit duplicate structured logs for the same error identity', () => { + const error = new Error('Duplicate error'); + const logSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + handleGlobalError('Duplicate error', 'app.js', 10, 5, error); + handleGlobalError('Duplicate error', 'app.js', 10, 5, error); + + expect(logSpy).toHaveBeenCalledTimes(1); + }); + + it('emits structured logs for Error events without an Error object', () => { + const logSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + handleGlobalError('Script error', 'unknown.js'); + + expect(logSpy).toHaveBeenCalledWith('[Global Error Handler]', { + message: 'Script error', + source: 'unknown.js', + line: undefined, + column: undefined, + error: null, + timestamp: expect.any(Number), + }); + }); + + it('allows both unhandled and boundary-caught errors to work simultaneously without conflict', () => { + const logSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + // Boundary catches an error + const caughtError = new Error('Boundary caught error'); + markErrorAsCaught(caughtError); + + // An unrelated unhandled error occurs elsewhere + const unhandledError = new Error('Unhandled timeout error'); + + handleGlobalError('Unhandled timeout error', 'app.js', 15, 3, unhandledError); + handleGlobalError('Boundary caught error', 'LandingPage.tsx', 852, 10, caughtError); + + // Only the unhandled error should be logged, once + expect(logSpy).toHaveBeenCalledTimes(1); + expect(logSpy).toHaveBeenCalledWith( + '[Global Error Handler]', + expect.objectContaining({ + error: expect.objectContaining({ + message: 'Unhandled timeout error', + }), + }) + ); + }); + + it('handles string message errors without an Error object', () => { + const logSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + handleGlobalError('String error message'); + + expect(logSpy).toHaveBeenCalledTimes(1); + expect(logSpy).toHaveBeenCalledWith( + '[Global Error Handler]', + expect.objectContaining({ + message: 'String error message', + error: null, + }) + ); + }); + }); + + describe('emitStructuredLog', () => { + it('outputs a structured error log with all fields', () => { + const logSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const error = new Error('detailed error'); + + emitStructuredLog({ + message: 'detailed error', + source: 'test.js', + lineno: 42, + colno: 10, + error, + timestamp: 1234567890, + }); + + expect(logSpy).toHaveBeenCalledWith('[Global Error Handler]', { + message: 'detailed error', + source: 'test.js', + line: 42, + column: 10, + error: { + name: 'Error', + message: 'detailed error', + stack: error.stack, + }, + timestamp: 1234567890, + }); + }); + + it('handles null error gracefully', () => { + const logSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + emitStructuredLog({ + message: 'Script error', + source: 'unknown.js', + lineno: 0, + colno: 0, + error: undefined, + timestamp: 1234567890, + }); + + expect(logSpy).toHaveBeenCalledWith('[Global Error Handler]', { + message: 'Script error', + source: 'unknown.js', + line: 0, + column: 0, + error: null, + timestamp: 1234567890, + }); + }); + }); + + describe('markErrorAsCaught and wasErrorAlreadyCaught', () => { + it('correctly marks and identifies caught errors', () => { + const error = new Error('test error'); + + expect(wasErrorAlreadyCaught(error)).toBe(false); + + markErrorAsCaught(error); + + expect(wasErrorAlreadyCaught(error)).toBe(true); + }); + + it('identifies errors with same message and stack as caught', () => { + const error1 = new Error('same error'); + const error2 = new Error('same error'); + + // They should have the same stack (same line in test) + error2.stack = error1.stack; + + markErrorAsCaught(error1); + + expect(wasErrorAlreadyCaught(error2)).toBe(true); + }); + + it('distinguishes different errors', () => { + const error1 = new Error('error one'); + const error2 = new Error('error two'); + + markErrorAsCaught(error1); + + expect(wasErrorAlreadyCaught(error2)).toBe(false); + }); + + it('marks errors as caught via the mock error boundary integration', () => { + const error = new Error('Error caught by SectionErrorBoundary'); + markErrorAsCaught(error); + + expect(wasErrorAlreadyCaught(error)).toBe(true); + }); + }); +}); diff --git a/src/utils/__tests__/handleDisplay.utils.test.ts b/src/utils/__tests__/handleDisplay.utils.test.ts index e93e0e1b..11e78299 100644 --- a/src/utils/__tests__/handleDisplay.utils.test.ts +++ b/src/utils/__tests__/handleDisplay.utils.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { formatCreatorHandle } from '../handleDisplay.utils'; +import { formatCreatorHandle, truncateHandle } from '../handleDisplay.utils'; describe('formatCreatorHandle', () => { it('lowercases mixed-case handles and prepends @', () => { @@ -32,7 +32,9 @@ describe('formatCreatorHandle', () => { }); it('is idempotent: formatting an already-formatted handle is a no-op', () => { - expect(formatCreatorHandle(formatCreatorHandle('ARivers'))).toBe('@arivers'); + expect(formatCreatorHandle(formatCreatorHandle('ARivers'))).toBe( + '@arivers' + ); }); it('does not modify the underlying string the caller passes in', () => { @@ -44,3 +46,35 @@ describe('formatCreatorHandle', () => { expect(raw).toBe('ARivers'); }); }); + +describe('truncateHandle', () => { + it('returns short handle unchanged (under max length)', () => { + expect(truncateHandle('@short', 20)).toBe('@short'); + expect(truncateHandle('abc', 5)).toBe('abc'); + }); + + it('returns exact max handle unchanged', () => { + expect(truncateHandle('12345678901234567890', 20)).toBe( + '12345678901234567890' + ); + expect(truncateHandle('abcde', 5)).toBe('abcde'); + }); + + it('truncates one over max handle with ellipsis', () => { + expect(truncateHandle('123456789012345678901', 20)).toBe( + '12345678901234567890...' + ); + expect(truncateHandle('abcdef', 5)).toBe('abcde...'); + }); + + it('uses default max of 20 characters when maxLength is not specified', () => { + // Exactly 20 chars should not be truncated + expect(truncateHandle('12345678901234567890')).toBe( + '12345678901234567890' + ); + // 21 chars (one over max) should be truncated + expect(truncateHandle('123456789012345678901')).toBe( + '12345678901234567890...' + ); + }); +}); diff --git a/src/utils/__tests__/isOwnWallet.test.ts b/src/utils/__tests__/isOwnWallet.test.ts new file mode 100644 index 00000000..1fc3e528 --- /dev/null +++ b/src/utils/__tests__/isOwnWallet.test.ts @@ -0,0 +1,47 @@ +import { describe, it, expect } from 'vitest'; +import { isOwnWallet } from '../isOwnWallet'; + +describe('isOwnWallet', () => { + it('returns true when addresses match exactly', () => { + expect(isOwnWallet('0xABC', '0xABC')).toBe(true); + }); + + it('returns true when addresses match case-insensitively', () => { + expect(isOwnWallet('0xABC', '0xabc')).toBe(true); + expect(isOwnWallet('0xabc', '0xABC')).toBe(true); + }); + + it('returns false when addresses differ', () => { + expect(isOwnWallet('0xABC', '0xDEF')).toBe(false); + }); + + it('returns false when connected address is null', () => { + expect(isOwnWallet(null, '0xABC')).toBe(false); + }); + + it('returns false when creator address is null', () => { + expect(isOwnWallet('0xABC', null)).toBe(false); + }); + + it('returns false when both addresses are null', () => { + expect(isOwnWallet(null, null)).toBe(false); + }); + + it('returns false when connected address is undefined', () => { + expect(isOwnWallet(undefined, '0xABC')).toBe(false); + }); + + it('returns false when creator address is undefined', () => { + expect(isOwnWallet('0xABC', undefined)).toBe(false); + }); + + it('returns false when both addresses are undefined', () => { + expect(isOwnWallet(undefined, undefined)).toBe(false); + }); + + it('returns false for empty strings', () => { + expect(isOwnWallet('', '0xABC')).toBe(false); + expect(isOwnWallet('0xABC', '')).toBe(false); + expect(isOwnWallet('', '')).toBe(false); + }); +}); diff --git a/src/utils/__tests__/keyHolderRanking.utils.test.ts b/src/utils/__tests__/keyHolderRanking.utils.test.ts new file mode 100644 index 00000000..6384b69e --- /dev/null +++ b/src/utils/__tests__/keyHolderRanking.utils.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from 'vitest'; +import { rankKeyHolders, type KeyHolder } from '@/utils/keyHolderRanking.utils'; + +function holder(id: string, keyCount: number): KeyHolder { + return { id, displayName: `Holder ${id}`, keyCount }; +} + +describe('rankKeyHolders', () => { + it('sorts a list of three holders descending by key count', () => { + const ranked = rankKeyHolders([holder('a', 5), holder('b', 20), holder('c', 10)]); + expect(ranked.map(h => h.id)).toEqual(['b', 'c', 'a']); + }); + + it('assigns sequential ranks 1, 2, 3 in the sorted order', () => { + const ranked = rankKeyHolders([holder('a', 5), holder('b', 20), holder('c', 10)]); + expect(ranked.map(h => h.rank)).toEqual([1, 2, 3]); + }); + + it('computes share percentages that sum to 100% across all holders', () => { + const ranked = rankKeyHolders([holder('a', 30), holder('b', 50), holder('c', 20)]); + const total = ranked.reduce((sum, h) => sum + h.sharePercent, 0); + expect(total).toBeCloseTo(100, 10); + }); + + it('gives a single holder with all keys a 100% share', () => { + const ranked = rankKeyHolders([holder('a', 42)]); + expect(ranked).toHaveLength(1); + expect(ranked[0]!.sharePercent).toBe(100); + expect(ranked[0]!.rank).toBe(1); + }); + + it('renders a tie in key count at the same rank', () => { + const ranked = rankKeyHolders([holder('a', 10), holder('b', 10), holder('c', 5)]); + const [first, second, third] = ranked; + + expect(first!.keyCount).toBe(10); + expect(second!.keyCount).toBe(10); + expect(first!.rank).toBe(1); + expect(second!.rank).toBe(1); + // Competition ranking: the next distinct count resumes at its + // 1-indexed position (3rd), not incrementing by 1 (2nd). + expect(third!.rank).toBe(3); + }); + + it('splits share percentage evenly between exactly tied holders', () => { + const ranked = rankKeyHolders([holder('a', 10), holder('b', 10)]); + expect(ranked[0]!.sharePercent).toBe(50); + expect(ranked[1]!.sharePercent).toBe(50); + }); + + it('handles three-way ties by giving the following holder rank 4', () => { + const ranked = rankKeyHolders([ + holder('a', 10), + holder('b', 10), + holder('c', 10), + holder('d', 5), + ]); + expect(ranked.map(h => h.rank)).toEqual([1, 1, 1, 4]); + }); + + it('returns an empty array for an empty holder list', () => { + expect(rankKeyHolders([])).toEqual([]); + }); + + it('does not mutate the input array', () => { + const input = [holder('a', 5), holder('b', 20)]; + const inputCopy = [...input]; + rankKeyHolders(input); + expect(input).toEqual(inputCopy); + }); +}); diff --git a/src/utils/__tests__/keyPriceDisplay.utils.test.ts b/src/utils/__tests__/keyPriceDisplay.utils.test.ts index e1f0e012..b53e7275 100644 --- a/src/utils/__tests__/keyPriceDisplay.utils.test.ts +++ b/src/utils/__tests__/keyPriceDisplay.utils.test.ts @@ -3,6 +3,7 @@ import { formatCreatorKeyPriceDisplay, formatDisplayKeyPrice, resolveCreatorKeyPriceStroops, + formatKeyPrice, } from '../keyPriceDisplay.utils'; import { STROOPS_PER_XLM } from '@/constants/stellar'; @@ -41,3 +42,26 @@ describe('formatCreatorKeyPriceDisplay', () => { ); }); }); + +describe('formatKeyPrice', () => { + it('formats zero correctly with 4 decimal places', () => { + expect(formatKeyPrice(0n)).toBe('0.0000 XLM'); + }); + + it('formats sub-1 XLM values with 4 decimal places', () => { + expect(formatKeyPrice(5_000_000n)).toBe('0.5000 XLM'); + expect(formatKeyPrice(123_456n)).toBe('0.0123 XLM'); + expect(formatKeyPrice(123_556n)).toBe('0.0124 XLM'); // rounds up + }); + + it('formats exactly 1 XLM with 2 decimal places', () => { + expect(formatKeyPrice(10_000_000n)).toBe('1.00 XLM'); + }); + + it('formats large values with 2 decimal places and commas', () => { + expect(formatKeyPrice(15_000_000n)).toBe('1.50 XLM'); + expect(formatKeyPrice(123_456_789n)).toBe('12.35 XLM'); + expect(formatKeyPrice(10_000_000_000n)).toBe('1,000.00 XLM'); + }); +}); + diff --git a/src/utils/__tests__/lineClamp.utils.test.ts b/src/utils/__tests__/lineClamp.utils.test.ts new file mode 100644 index 00000000..23dd783d --- /dev/null +++ b/src/utils/__tests__/lineClamp.utils.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from 'vitest'; +import { + lineClampClassFor, + creatorCardSubtitleClampClass, + DEFAULT_CREATOR_CARD_SUBTITLE_MAX_LINES, +} from '../lineClamp.utils'; + +describe('lineClamp.utils', () => { + describe('DEFAULT_CREATOR_CARD_SUBTITLE_MAX_LINES', () => { + it('defaults to 2 lines for creator card subtitles', () => { + expect(DEFAULT_CREATOR_CARD_SUBTITLE_MAX_LINES).toBe(2); + }); + }); + + describe('creatorCardSubtitleClampClass', () => { + it('returns line-clamp-2 by default when no parameter is passed', () => { + expect(creatorCardSubtitleClampClass()).toBe('line-clamp-2'); + }); + + it('returns correct line-clamp class for specified line counts', () => { + expect(creatorCardSubtitleClampClass(1)).toBe('line-clamp-1'); + expect(creatorCardSubtitleClampClass(2)).toBe('line-clamp-2'); + expect(creatorCardSubtitleClampClass(3)).toBe('line-clamp-3'); + expect(creatorCardSubtitleClampClass(4)).toBe('line-clamp-4'); + expect(creatorCardSubtitleClampClass(5)).toBe('line-clamp-5'); + expect(creatorCardSubtitleClampClass(6)).toBe('line-clamp-6'); + }); + + it('caps higher values at line-clamp-6 to keep card heights bounded', () => { + expect(creatorCardSubtitleClampClass(7)).toBe('line-clamp-6'); + expect(creatorCardSubtitleClampClass(100)).toBe('line-clamp-6'); + }); + + it('returns empty string for null, undefined, 0, or negative values', () => { + expect(creatorCardSubtitleClampClass(null)).toBe(''); + expect(creatorCardSubtitleClampClass(0)).toBe(''); + expect(creatorCardSubtitleClampClass(-1)).toBe(''); + }); + }); + + describe('lineClampClassFor', () => { + it('returns empty string for profile variant', () => { + expect(lineClampClassFor('profile', 3)).toBe(''); + expect(lineClampClassFor('profile', null)).toBe(''); + expect(lineClampClassFor('profile', undefined)).toBe(''); + }); + + it('returns empty string for invalid maxLines', () => { + expect(lineClampClassFor('card', null)).toBe(''); + expect(lineClampClassFor('card', undefined)).toBe(''); + expect(lineClampClassFor('card', 0)).toBe(''); + expect(lineClampClassFor('card', -5)).toBe(''); + }); + + it('returns correct line-clamp classes for card variant', () => { + expect(lineClampClassFor('card', 1)).toBe('line-clamp-1'); + expect(lineClampClassFor('card', 2)).toBe('line-clamp-2'); + expect(lineClampClassFor('card', 3)).toBe('line-clamp-3'); + }); + }); +}); diff --git a/src/utils/__tests__/logger.test.ts b/src/utils/__tests__/logger.test.ts new file mode 100644 index 00000000..e394e27b --- /dev/null +++ b/src/utils/__tests__/logger.test.ts @@ -0,0 +1,34 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +describe('logger', () => { + beforeEach(() => { + vi.spyOn(console, 'debug').mockImplementation(() => {}); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('suppresses console.debug in the test environment (MODE=test)', async () => { + // Vitest sets import.meta.env.MODE to 'test' automatically. + // Re-import the logger fresh so the module-level isTestEnv check runs. + // We use a dynamic import with a cache-busting approach via vi.resetModules. + vi.resetModules(); + const { logger } = await import('@/utils/logger'); + + logger.debug('should be suppressed', { key: 'value' }); + + expect(console.debug).not.toHaveBeenCalled(); + }); + + it('exposes a debug method that accepts a message and optional fields', async () => { + // The logger module itself is importable and has the right shape. + vi.resetModules(); + const { logger } = await import('@/utils/logger'); + + expect(logger.debug).toBeTypeOf('function'); + // Calling it should not throw in any environment. + expect(() => logger.debug('test message', { a: 1 })).not.toThrow(); + expect(() => logger.debug('test message')).not.toThrow(); + }); +}); diff --git a/src/utils/__tests__/numberFormat.utils.test.ts b/src/utils/__tests__/numberFormat.utils.test.ts index b215130b..1f74df8e 100644 --- a/src/utils/__tests__/numberFormat.utils.test.ts +++ b/src/utils/__tests__/numberFormat.utils.test.ts @@ -3,7 +3,9 @@ import { formatNumber, formatCompactNumber, formatFollowerCount, + formatHolderCount, formatPercent, + bpsToPercent, } from '../numberFormat.utils'; // --------------------------------------------------------------------------- @@ -110,12 +112,18 @@ describe('formatNumber: Full Value Display for Tooltips', () => { // --------------------------------------------------------------------------- describe('formatCompactNumber: Configurable precision', () => { it('respects maximumFractionDigits option', () => { - expect(formatCompactNumber(1234, { maximumFractionDigits: 0 })).toBe('1K'); - expect(formatCompactNumber(1234, { maximumFractionDigits: 2 })).toBe('1.23K'); + expect(formatCompactNumber(1234, { maximumFractionDigits: 0 })).toBe( + '1K' + ); + expect(formatCompactNumber(1234, { maximumFractionDigits: 2 })).toBe( + '1.23K' + ); }); it('respects minimumFractionDigits option', () => { - expect(formatCompactNumber(1000000, { minimumFractionDigits: 2 })).toBe('1.00M'); + expect(formatCompactNumber(1000000, { minimumFractionDigits: 2 })).toBe( + '1.00M' + ); }); }); @@ -145,6 +153,34 @@ describe('formatFollowerCount: Legacy follower abbreviation', () => { }); }); +// --------------------------------------------------------------------------- +// Feature: Holder count formatting +// Validates: Issue #438 acceptance criteria +// --------------------------------------------------------------------------- +describe('formatHolderCount: Holder count abbreviation', () => { + it('returns values under 1000 as a plain string', () => { + expect(formatHolderCount(0)).toBe('0'); + expect(formatHolderCount(42)).toBe('42'); + expect(formatHolderCount(999)).toBe('999'); + }); + + it('formats values in the K range with one decimal place', () => { + expect(formatHolderCount(1200)).toBe('1.2K'); + expect(formatHolderCount(1500)).toBe('1.5K'); + expect(formatHolderCount(999_999)).toBe('1000K'); + }); + + it('formats values in the M range with one decimal place', () => { + expect(formatHolderCount(2_400_000)).toBe('2.4M'); + expect(formatHolderCount(1_250_000)).toBe('1.3M'); + }); + + it('handles boundary values at 1000 and 1000000', () => { + expect(formatHolderCount(1000)).toBe('1K'); + expect(formatHolderCount(1_000_000)).toBe('1M'); + }); +}); + // --------------------------------------------------------------------------- // Feature: Percentage formatting // Validates: Acceptance Criteria for badge display @@ -213,3 +249,29 @@ describe('Integration: Compact display with full tooltip pattern', () => { expect(tooltipValue).toBe('42'); }); }); + +// --------------------------------------------------------------------------- +// Feature: Bps to Percent formatting +// --------------------------------------------------------------------------- +describe('bpsToPercent: Basis points to percentage formatting', () => { + it('converts 500 bps to "5%"', () => { + expect(bpsToPercent(500)).toBe('5%'); + }); + + it('converts 250 bps to "2.5%"', () => { + expect(bpsToPercent(250)).toBe('2.5%'); + }); + + it('converts 0 bps to "0%"', () => { + expect(bpsToPercent(0)).toBe('0%'); + }); + + it('returns placeholder "—" for null or undefined', () => { + expect(bpsToPercent(null)).toBe('—'); + expect(bpsToPercent(undefined)).toBe('—'); + }); + + it('returns custom placeholder when provided', () => { + expect(bpsToPercent(null, { emptyPlaceholder: 'N/A' })).toBe('N/A'); + }); +}); diff --git a/src/utils/__tests__/preferences.sortOrder.test.ts b/src/utils/__tests__/preferences.sortOrder.test.ts new file mode 100644 index 00000000..c6334c6b --- /dev/null +++ b/src/utils/__tests__/preferences.sortOrder.test.ts @@ -0,0 +1,43 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { getPreference, setPreference } from '../preferences.utils'; + +const SORT_KEY = 'creator-list-sort-order'; +const DEFAULT_SORT = 'asc'; + +describe('sort order persistence via preferences.utils', () => { + beforeEach(() => { + window.localStorage.clear(); + }); + + it('persists the selected sort order across simulated remounts', () => { + setPreference(SORT_KEY, 'desc'); + + // Simulate component unmount/remount: re-read from storage with no in-memory state + const restored = getPreference(SORT_KEY, DEFAULT_SORT); + expect(restored).toBe('desc'); + }); + + it('returns the default sort order when storage is cleared', () => { + setPreference(SORT_KEY, 'desc'); + window.localStorage.clear(); + + const restored = getPreference(SORT_KEY, DEFAULT_SORT); + expect(restored).toBe(DEFAULT_SORT); + }); + + it('persists across multiple sort order changes', () => { + const orders = ['desc', 'asc', 'desc'] as const; + for (const order of orders) { + setPreference(SORT_KEY, order); + expect(getPreference(SORT_KEY, DEFAULT_SORT)).toBe(order); + } + }); + + it('isolates sort order per key — changing one key does not affect another', () => { + setPreference('sort-a', 'desc'); + setPreference('sort-b', 'asc'); + + expect(getPreference('sort-a', DEFAULT_SORT)).toBe('desc'); + expect(getPreference('sort-b', DEFAULT_SORT)).toBe('asc'); + }); +}); diff --git a/src/utils/__tests__/preferences.utils.test.ts b/src/utils/__tests__/preferences.utils.test.ts new file mode 100644 index 00000000..161c4381 --- /dev/null +++ b/src/utils/__tests__/preferences.utils.test.ts @@ -0,0 +1,33 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { getPreference, setPreference } from '../preferences.utils'; + +describe('preferences.utils', () => { + beforeEach(() => { + window.localStorage.clear(); + }); + + it('returns stored value for a key that exists', () => { + window.localStorage.setItem('testKey', JSON.stringify({ theme: 'dark' })); + const result = getPreference('testKey', { theme: 'light' }); + expect(result).toEqual({ theme: 'dark' }); + }); + + it('returns default value for a missing key', () => { + const result = getPreference('missingKey', 'default-value'); + expect(result).toBe('default-value'); + }); + + it('returns default value if stored JSON is malformed', () => { + window.localStorage.setItem('malformedKey', '{ invalid json '); + const result = getPreference('malformedKey', 'default-value'); + expect(result).toBe('default-value'); + }); + + it('set and get round-trip correctly', () => { + const complexValue = { sort: 'desc', filter: ['active', 'verified'] }; + setPreference('complexKey', complexValue); + + const result = getPreference('complexKey', { sort: 'asc', filter: [] }); + expect(result).toEqual(complexValue); + }); +}); diff --git a/src/utils/__tests__/priceChange.utils.test.ts b/src/utils/__tests__/priceChange.utils.test.ts new file mode 100644 index 00000000..631a9c95 --- /dev/null +++ b/src/utils/__tests__/priceChange.utils.test.ts @@ -0,0 +1,55 @@ +// src/utils/__tests__/priceChange.utils.test.ts +import { describe, expect, it } from 'vitest'; +import { computePriceChange } from '../priceChange.utils'; + +describe('computePriceChange', () => { + describe('price up', () => { + it('returns positive percent and direction up when price increases', () => { + const result = computePriceChange(112n, 100n); + expect(result.direction).toBe('up'); + expect(result.percent).toBeCloseTo(12); + }); + + it('handles a large price increase', () => { + const result = computePriceChange(200n, 100n); + expect(result.direction).toBe('up'); + expect(result.percent).toBeCloseTo(100); + }); + }); + + describe('price down', () => { + it('returns negative percent and direction down when price decreases', () => { + const result = computePriceChange(88n, 100n); + expect(result.direction).toBe('down'); + expect(result.percent).toBeCloseTo(-12); + }); + + it('handles a large price decrease', () => { + const result = computePriceChange(1n, 100n); + expect(result.direction).toBe('down'); + expect(result.percent).toBeCloseTo(-99); + }); + }); + + describe('flat — no change', () => { + it('returns flat with 0 percent when current equals previous', () => { + const result = computePriceChange(100n, 100n); + expect(result.direction).toBe('flat'); + expect(result.percent).toBe(0); + }); + }); + + describe('flat — zero previous value', () => { + it('returns flat with 0 percent when previous is zero', () => { + const result = computePriceChange(100n, 0n); + expect(result.direction).toBe('flat'); + expect(result.percent).toBe(0); + }); + + it('returns flat when both current and previous are zero', () => { + const result = computePriceChange(0n, 0n); + expect(result.direction).toBe('flat'); + expect(result.percent).toBe(0); + }); + }); +}); \ No newline at end of file diff --git a/src/utils/__tests__/stellar.utils.test.ts b/src/utils/__tests__/stellar.utils.test.ts new file mode 100644 index 00000000..8f0b442d --- /dev/null +++ b/src/utils/__tests__/stellar.utils.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, it } from 'vitest'; +import { + buildStellarExpertTxUrl, + truncateTxHash, +} from '@/constants/stellar'; + +describe('buildStellarExpertTxUrl', () => { + it('builds a testnet explorer URL', () => { + const txHash = + 'abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890'; + const url = buildStellarExpertTxUrl(txHash, 'testnet'); + + expect(url).toBe( + `https://stellar.expert/explorer/testnet/tx/${txHash}` + ); + }); + + it('builds a mainnet explorer URL', () => { + const txHash = + 'abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890'; + const url = buildStellarExpertTxUrl(txHash, 'mainnet'); + + expect(url).toBe( + `https://stellar.expert/explorer/mainnet/tx/${txHash}` + ); + }); + + it('correctly distinguishes mainnet from testnet URLs', () => { + const txHash = 'abc123'; + expect(buildStellarExpertTxUrl(txHash, 'mainnet')).toContain('/mainnet/'); + expect(buildStellarExpertTxUrl(txHash, 'testnet')).toContain('/testnet/'); + expect(buildStellarExpertTxUrl(txHash, 'mainnet')).not.toContain( + '/testnet/' + ); + expect(buildStellarExpertTxUrl(txHash, 'testnet')).not.toContain( + '/mainnet/' + ); + }); + + it('includes the full tx hash in the URL', () => { + const txHash = 'uniquetxhash123456'; + const url = buildStellarExpertTxUrl(txHash, 'testnet'); + expect(url).toContain(txHash); + }); +}); + +describe('truncateTxHash', () => { + const fullHash = + '0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890'; + + it('truncates a long hash with default prefix and suffix lengths', () => { + const result = truncateTxHash(fullHash); + + expect(result).toBe('0xabcdef…567890'); + }); + + it('uses 8 prefix chars and 6 suffix chars by default', () => { + const result = truncateTxHash(fullHash); + + // First 8 chars: '0xabcdef' + expect(result.startsWith('0xabcdef')).toBe(true); + // Last 6 chars: '567890' + expect(result.endsWith('567890')).toBe(true); + // Has ellipsis separator + expect(result).toContain('…'); + }); + + it('respects custom prefix length', () => { + const result = truncateTxHash(fullHash, 4, 4); + + expect(result).toBe('0xab…7890'); + }); + + it('respects custom suffix length', () => { + const result = truncateTxHash(fullHash, 6, 8); + + // First 6 chars: '0xabcd' + expect(result.startsWith('0xabcd')).toBe(true); + // Last 8 chars of fullHash + expect(result.endsWith(fullHash.slice(-8))).toBe(true); + expect(result).toContain('…'); + }); + + it('returns hash unchanged when it is short enough', () => { + const shortHash = '0xabc123'; + const result = truncateTxHash(shortHash); + + // Hash length (8) ≤ prefixLen(8) + suffixLen(6) + 1 = 15 + expect(result).toBe(shortHash); + }); + + it('handles hashes without 0x prefix', () => { + const hash = 'abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890'; + const result = truncateTxHash(hash); + + expect(result).toBe('abcdef12…567890'); + }); +}); diff --git a/src/utils/__tests__/stellarLedger.integration.test.ts b/src/utils/__tests__/stellarLedger.integration.test.ts new file mode 100644 index 00000000..8f173f34 --- /dev/null +++ b/src/utils/__tests__/stellarLedger.integration.test.ts @@ -0,0 +1,40 @@ +import { describe, it, expect } from 'vitest'; +import { ledgerToTimestamp } from '../stellarLedger.utils'; + +describe('ledgerToTimestamp - Integration Test with Known Values', () => { + // A known reference point: ledger 50000000 and some arbitrary timestamp. + const KNOWN_REFERENCE_LEDGER = 50000000; + // e.g. 2024-01-01T00:00:00.000Z + const KNOWN_REFERENCE_TIMESTAMP = new Date('2024-01-01T00:00:00.000Z').getTime(); + + it('returns exactly the reference timestamp when ledger equals reference', () => { + const result = ledgerToTimestamp( + KNOWN_REFERENCE_LEDGER, + KNOWN_REFERENCE_LEDGER, + KNOWN_REFERENCE_TIMESTAMP + ); + expect(result.getTime()).toBe(KNOWN_REFERENCE_TIMESTAMP); + }); + + it('returns correctly estimated future date (100 ledgers ahead)', () => { + const futureLedger = KNOWN_REFERENCE_LEDGER + 100; + const result = ledgerToTimestamp( + futureLedger, + KNOWN_REFERENCE_LEDGER, + KNOWN_REFERENCE_TIMESTAMP + ); + const expectedTimestamp = KNOWN_REFERENCE_TIMESTAMP + 100 * 5000; + expect(result.getTime()).toBe(expectedTimestamp); + }); + + it('returns correctly estimated past date (100 ledgers behind)', () => { + const pastLedger = KNOWN_REFERENCE_LEDGER - 100; + const result = ledgerToTimestamp( + pastLedger, + KNOWN_REFERENCE_LEDGER, + KNOWN_REFERENCE_TIMESTAMP + ); + const expectedTimestamp = KNOWN_REFERENCE_TIMESTAMP - 100 * 5000; + expect(result.getTime()).toBe(expectedTimestamp); + }); +}); diff --git a/src/utils/__tests__/stellarLedger.utils.test.ts b/src/utils/__tests__/stellarLedger.utils.test.ts new file mode 100644 index 00000000..0473b804 --- /dev/null +++ b/src/utils/__tests__/stellarLedger.utils.test.ts @@ -0,0 +1,35 @@ +import { describe, it, expect } from 'vitest'; +import { ledgerToTimestamp } from '../stellarLedger.utils'; + +describe('ledgerToTimestamp', () => { + it('returns correct estimated Date for ledger in the past', () => { + const referenceLedger = 1000; + const referenceTimestamp = Date.now(); + const pastLedger = 900; // 100 ledgers before reference + + const result = ledgerToTimestamp(pastLedger, referenceLedger, referenceTimestamp); + const expectedTimestamp = referenceTimestamp - 100 * 5000; // 100 * 5 seconds in ms + + expect(result.getTime()).toBe(expectedTimestamp); + }); + + it('returns correct estimated Date for ledger in the future', () => { + const referenceLedger = 1000; + const referenceTimestamp = Date.now(); + const futureLedger = 1100; // 100 ledgers after reference + + const result = ledgerToTimestamp(futureLedger, referenceLedger, referenceTimestamp); + const expectedTimestamp = referenceTimestamp + 100 * 5000; // 100 * 5 seconds in ms + + expect(result.getTime()).toBe(expectedTimestamp); + }); + + it('returns exactly the reference timestamp when ledger equals reference', () => { + const referenceLedger = 1000; + const referenceTimestamp = Date.now(); + + const result = ledgerToTimestamp(referenceLedger, referenceLedger, referenceTimestamp); + + expect(result.getTime()).toBe(referenceTimestamp); + }); +}); diff --git a/src/utils/__tests__/time.utils.test.ts b/src/utils/__tests__/time.utils.test.ts new file mode 100644 index 00000000..bc06c10f --- /dev/null +++ b/src/utils/__tests__/time.utils.test.ts @@ -0,0 +1,63 @@ +import { describe, it, expect } from 'vitest'; +import { formatRelativeTimeLabel } from '../time.utils'; + +function makeDate(secondsAgo: number, from: Date): Date { + return new Date(from.getTime() - secondsAgo * 1000); +} + +describe('formatRelativeTimeLabel', () => { + const now = new Date('2026-06-27T12:00:00.000Z'); + + it('returns "just now" for 0 seconds ago', () => { + expect(formatRelativeTimeLabel(now, now)).toBe('just now'); + }); + + it('returns "just now" for 59 seconds ago', () => { + expect(formatRelativeTimeLabel(makeDate(59, now), now)).toBe('just now'); + }); + + it('returns "1 minutes ago" for exactly 60 seconds ago', () => { + expect(formatRelativeTimeLabel(makeDate(60, now), now)).toBe('1 minutes ago'); + }); + + it('returns "45 minutes ago" for 45 minutes ago', () => { + expect(formatRelativeTimeLabel(makeDate(45 * 60, now), now)).toBe('45 minutes ago'); + }); + + it('returns "59 minutes ago" for 59 minutes 59 seconds ago', () => { + expect(formatRelativeTimeLabel(makeDate(59 * 60 + 59, now), now)).toBe('59 minutes ago'); + }); + + it('returns "1 hours ago" for exactly 1 hour ago', () => { + expect(formatRelativeTimeLabel(makeDate(3600, now), now)).toBe('1 hours ago'); + }); + + it('returns "23 hours ago" for 23 hours ago', () => { + expect(formatRelativeTimeLabel(makeDate(23 * 3600, now), now)).toBe('23 hours ago'); + }); + + it('returns "1 days ago" for exactly 24 hours ago', () => { + expect(formatRelativeTimeLabel(makeDate(24 * 3600, now), now)).toBe('1 days ago'); + }); + + it('returns "29 days ago" for 29 days ago', () => { + expect(formatRelativeTimeLabel(makeDate(29 * 24 * 3600, now), now)).toBe('29 days ago'); + }); + + it('returns a formatted date for exactly 30 days ago', () => { + const date = makeDate(30 * 24 * 3600, now); + const result = formatRelativeTimeLabel(date, now); + expect(result).toMatch(/\d{1,2} \w+ \d{4}/); + }); + + it('returns a formatted date for dates older than 30 days', () => { + const date = new Date('2026-01-12T00:00:00.000Z'); + const result = formatRelativeTimeLabel(date, now); + expect(result).toMatch(/12 Jan 2026/); + }); + + it('defaults now to the current time when omitted', () => { + const veryRecentDate = new Date(Date.now() - 5000); + expect(formatRelativeTimeLabel(veryRecentDate)).toBe('just now'); + }); +}); diff --git a/src/utils/__tests__/toast.transactionSuccess.test.tsx b/src/utils/__tests__/toast.transactionSuccess.test.tsx new file mode 100644 index 00000000..5eade27d --- /dev/null +++ b/src/utils/__tests__/toast.transactionSuccess.test.tsx @@ -0,0 +1,177 @@ +import { render, screen } from '@testing-library/react'; +import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'; +import toast, { type Toast } from 'react-hot-toast'; +import showToast from '@/utils/toast.util'; + +// Mock react-hot-toast +vi.mock('react-hot-toast', () => ({ + default: { + remove: vi.fn(), + custom: vi.fn(), + }, +})); + +describe('showToast.transactionSuccess', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('shows transaction confirmed toast with truncated tx hash', () => { + const txHash = + '0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890'; + const explorerUrl = 'https://stellar.expert/explorer/testnet/tx/' + txHash; + + showToast.transactionSuccess( + 'Transaction confirmed', + '0xabcdef…567890', + txHash, + explorerUrl + ); + + expect(toast.remove).toHaveBeenCalledOnce(); + expect(toast.custom).toHaveBeenCalledOnce(); + + // Verify the duration is 6 seconds (6000ms) + const [, options] = vi.mocked(toast.custom).mock.calls[0]; + expect(options).toMatchObject({ duration: 6000 }); + }); + + it('renders transaction toast with View on Stellar Expert link', () => { + const txHash = + '0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890'; + const explorerUrl = 'https://stellar.expert/explorer/testnet/tx/' + txHash; + + // Capture the JSX component passed to toast.custom + showToast.transactionSuccess( + 'Transaction confirmed', + '0xabcdef…567890', + txHash, + explorerUrl + ); + + const [toastComponent] = vi.mocked(toast.custom).mock.calls[0]; + const mockToastState = { visible: true, id: '1' }; + + // Render the toast component + render( +
{toastComponent(mockToastState as Toast)}
+ ); + + // Check that the title is rendered + expect(screen.getByText('Transaction confirmed')).toBeInTheDocument(); + + // Check that the truncated hash message is shown + expect(screen.getByText('0xabcdef…567890')).toBeInTheDocument(); + + // Check that the "View on Stellar Expert" link is present + const explorerLink = screen.getByText('View on Stellar Expert'); + expect(explorerLink).toBeInTheDocument(); + expect(explorerLink.closest('a')).toHaveAttribute('href', explorerUrl); + expect(explorerLink.closest('a')).toHaveAttribute('target', '_blank'); + expect(explorerLink.closest('a')).toHaveAttribute( + 'rel', + 'noopener noreferrer' + ); + }); + + it('shows transaction hash in TransactionHashRow when txHash is provided', () => { + const txHash = + '0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890'; + const explorerUrl = 'https://stellar.expert/explorer/mainnet/tx/' + txHash; + + showToast.transactionSuccess( + 'Transaction confirmed', + 'Transaction completed successfully', + txHash, + explorerUrl + ); + + const [toastComponent] = vi.mocked(toast.custom).mock.calls[0]; + const mockToastState = { visible: true, id: '1' }; + + render(
{toastComponent(mockToastState as Toast)}
); + + // The TransactionHashRow will display the full hash somewhere (likely in a copy field) + // We can't easily test the internal CopyField behavior without more mocking, + // but we can verify the structure is rendered + expect(screen.getByText('Transaction confirmed')).toBeInTheDocument(); + }); + + it('auto-dismisses after 6 seconds', () => { + const txHash = '0xabc123'; + const explorerUrl = 'https://stellar.expert/explorer/testnet/tx/0xabc123'; + + showToast.transactionSuccess( + 'Transaction confirmed', + '0xabc123', + txHash, + explorerUrl + ); + + const [, options] = vi.mocked(toast.custom).mock.calls[0]; + expect(options?.duration).toBe(6000); + }); + + it('works without explorer URL', () => { + const txHash = + '0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890'; + + showToast.transactionSuccess( + 'Transaction confirmed', + '0xabcdef…567890', + txHash, + undefined // No explorer URL + ); + + const [toastComponent] = vi.mocked(toast.custom).mock.calls[0]; + const mockToastState = { visible: true, id: '1' }; + + render(
{toastComponent(mockToastState as Toast)}
); + + expect(screen.getByText('Transaction confirmed')).toBeInTheDocument(); + // No "View on Stellar Expert" link should be shown + expect( + screen.queryByText('View on Stellar Expert') + ).not.toBeInTheDocument(); + }); + + it('removes previous toasts before showing new one (no duplicates)', () => { + showToast.transactionSuccess('Transaction confirmed', '0xabc', 'hash', 'url'); + + expect(toast.remove).toHaveBeenCalledOnce(); + + // Show another toast + showToast.transactionSuccess( + 'Another transaction', + '0xdef', + 'hash2', + 'url2' + ); + + expect(toast.remove).toHaveBeenCalledTimes(2); + }); + + it('handles mainnet network correctly', () => { + const txHash = '0x123abc'; + const explorerUrl = 'https://stellar.expert/explorer/mainnet/tx/0x123abc'; + + showToast.transactionSuccess( + 'Transaction confirmed', + '0x123abc', + txHash, + explorerUrl + ); + + const [toastComponent] = vi.mocked(toast.custom).mock.calls[0]; + const mockToastState = { visible: true, id: '1' }; + + render(
{toastComponent(mockToastState as Toast)}
); + + const explorerLink = screen.getByText('View on Stellar Expert'); + expect(explorerLink.closest('a')).toHaveAttribute('href', explorerUrl); + }); +}); diff --git a/src/utils/__tests__/toast.util.test.tsx b/src/utils/__tests__/toast.util.test.tsx new file mode 100644 index 00000000..39dc1812 --- /dev/null +++ b/src/utils/__tests__/toast.util.test.tsx @@ -0,0 +1,95 @@ +import { render, screen, act } from '@testing-library/react'; +import { Toaster } from 'react-hot-toast'; +import toast from 'react-hot-toast'; +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import showToast from '../toast.util'; + +describe('toast util auto-dismiss and manual close', () => { + beforeEach(() => { + vi.useFakeTimers(); + Object.defineProperty(window, 'matchMedia', { + writable: true, + value: vi.fn().mockImplementation((query: string) => ({ + matches: false, + media: query, + onchange: null, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + addListener: vi.fn(), + removeListener: vi.fn(), + dispatchEvent: vi.fn(), + })), + }); + }); + + afterEach(() => { + toast.remove(); + vi.useRealTimers(); + }); + + it('toast visible immediately after render', () => { + render(); + + act(() => { + showToast.success('Success message'); + }); + + expect(screen.getByText('Success message')).toBeInTheDocument(); + }); + + it('toast still visible before auto-dismiss fires', () => { + render(); + + act(() => { + showToast.success('Success message'); + }); + + expect(screen.getByText('Success message')).toBeInTheDocument(); + + act(() => { + vi.advanceTimersByTime(3999); + }); + + expect(screen.getByText('Success message')).toBeInTheDocument(); + }); + + it('toast removed after auto-dismiss and remove delay', () => { + render(); + + act(() => { + showToast.success('Success message'); + }); + + expect(screen.getByText('Success message')).toBeInTheDocument(); + + act(() => { + vi.advanceTimersByTime(4000); + }); + + act(() => { + vi.advanceTimersByTime(1000); + }); + + expect(screen.queryByText('Success message')).not.toBeInTheDocument(); + }); + + it('manual close removes the toast before the auto-dismiss timer fires', () => { + render(); + + act(() => { + showToast.success('Success message'); + }); + + expect(screen.getByText('Success message')).toBeInTheDocument(); + + act(() => { + toast.dismiss(); + }); + + act(() => { + vi.advanceTimersByTime(1000); + }); + + expect(screen.queryByText('Success message')).not.toBeInTheDocument(); + }); +}); diff --git a/src/utils/__tests__/tradeQuantityValidation.test.ts b/src/utils/__tests__/tradeQuantityValidation.test.ts new file mode 100644 index 00000000..a86345cc --- /dev/null +++ b/src/utils/__tests__/tradeQuantityValidation.test.ts @@ -0,0 +1,281 @@ +import { describe, it, expect } from 'vitest'; +import { + validateTradeQuantity, + formatValidationError, + type TradeValidationError, +} from '../tradeQuantityValidation'; + +describe('Trade Quantity Validation', () => { + describe('validateTradeQuantity - Buy Side', () => { + const side = 'buy' as const; + const holdings = 10; + + it('accepts valid positive integer', () => { + const result = validateTradeQuantity('2', side, holdings); + expect(result.valid).toBe(true); + expect(result.error).toBeUndefined(); + expect(result.message).toBeUndefined(); + }); + + it('accepts single digit', () => { + const result = validateTradeQuantity('1', side, holdings); + expect(result.valid).toBe(true); + }); + + it('accepts large quantity', () => { + const result = validateTradeQuantity('1000', side, holdings); + expect(result.valid).toBe(true); + }); + + it('accepts decimal that parses to valid number', () => { + const result = validateTradeQuantity('2.5', side, holdings); + expect(result.valid).toBe(true); + }); + + it('rejects empty string', () => { + const result = validateTradeQuantity('', side, holdings); + expect(result.valid).toBe(false); + expect(result.error).toBe('empty'); + expect(result.message).toBe('Please enter an amount.'); + }); + + it('rejects whitespace-only string', () => { + const result = validateTradeQuantity(' ', side, holdings); + expect(result.valid).toBe(false); + expect(result.error).toBe('empty'); + }); + + it('rejects zero', () => { + const result = validateTradeQuantity('0', side, holdings); + expect(result.valid).toBe(false); + expect(result.error).toBe('zero-or-negative'); + expect(result.message).toBe('Amount must be greater than zero.'); + }); + + it('rejects negative value', () => { + const result = validateTradeQuantity('-5', side, holdings); + expect(result.valid).toBe(false); + expect(result.error).toBe('zero-or-negative'); + expect(result.message).toBe('Amount must be greater than zero.'); + }); + + it('rejects negative decimal', () => { + const result = validateTradeQuantity('-2.5', side, holdings); + expect(result.valid).toBe(false); + expect(result.error).toBe('zero-or-negative'); + }); + + it('rejects non-numeric string', () => { + const result = validateTradeQuantity('abc', side, holdings); + expect(result.valid).toBe(false); + expect(result.error).toBe('invalid-number'); + expect(result.message).toBe('Amount must be a valid number.'); + }); + + it('rejects string with mixed letters and numbers', () => { + const result = validateTradeQuantity('5abc', side, holdings); + expect(result.valid).toBe(false); + expect(result.error).toBe('invalid-number'); + }); + + it('rejects special characters', () => { + const result = validateTradeQuantity('5@#$', side, holdings); + expect(result.valid).toBe(false); + expect(result.error).toBe('invalid-number'); + }); + + it('buy side: does not check balance (no insufficient-balance error)', () => { + // Even though holdings is 10, buy side should not reject high quantities + const result = validateTradeQuantity('1000', side, 10); + expect(result.valid).toBe(true); + expect(result.error).not.toBe('insufficient-balance'); + }); + }); + + describe('validateTradeQuantity - Sell Side', () => { + const side = 'sell' as const; + + it('accepts quantity equal to holdings', () => { + const result = validateTradeQuantity('10', side, 10); + expect(result.valid).toBe(true); + }); + + it('accepts quantity less than holdings', () => { + const result = validateTradeQuantity('5', side, 10); + expect(result.valid).toBe(true); + }); + + it('rejects zero', () => { + const result = validateTradeQuantity('0', side, 10); + expect(result.valid).toBe(false); + expect(result.error).toBe('zero-or-negative'); + }); + + it('rejects negative value', () => { + const result = validateTradeQuantity('-3', side, 10); + expect(result.valid).toBe(false); + expect(result.error).toBe('zero-or-negative'); + }); + + it('rejects quantity exceeding holdings', () => { + const result = validateTradeQuantity('15', side, 10); + expect(result.valid).toBe(false); + expect(result.error).toBe('insufficient-balance'); + expect(result.message).toContain('10 keys'); + }); + + it('rejects quantity significantly exceeding holdings', () => { + const result = validateTradeQuantity('1000', side, 10); + expect(result.valid).toBe(false); + expect(result.error).toBe('insufficient-balance'); + }); + + it('rejects non-numeric string', () => { + const result = validateTradeQuantity('not-a-number', side, 10); + expect(result.valid).toBe(false); + expect(result.error).toBe('invalid-number'); + }); + + it('insufficient-balance message includes formatted holding count', () => { + const result = validateTradeQuantity('5', side, 2); + expect(result.message).toContain('2 keys'); + }); + + it('handles zero holdings - rejects any positive quantity', () => { + const result = validateTradeQuantity('1', side, 0); + expect(result.valid).toBe(false); + expect(result.error).toBe('insufficient-balance'); + }); + + it('handles fractional holdings comparison', () => { + const result = validateTradeQuantity('5.1', side, 5); + expect(result.valid).toBe(false); + expect(result.error).toBe('insufficient-balance'); + }); + }); + + describe('Edge Cases and Whitespace Handling', () => { + it('strips leading/trailing whitespace', () => { + const result = validateTradeQuantity(' 5 ', 'buy', 10); + expect(result.valid).toBe(true); + }); + + it('handles tab and newline characters', () => { + const result = validateTradeQuantity('\t5\n', 'buy', 10); + expect(result.valid).toBe(true); + }); + + it('rejects infinity string', () => { + const result = validateTradeQuantity('Infinity', 'buy', 10); + expect(result.valid).toBe(false); + expect(result.error).toBe('invalid-number'); + }); + + it('rejects NaN string', () => { + const result = validateTradeQuantity('NaN', 'buy', 10); + expect(result.valid).toBe(false); + expect(result.error).toBe('invalid-number'); + }); + + it('accepts scientific notation that parses to finite number', () => { + const result = validateTradeQuantity('1e2', 'buy', 10); + expect(result.valid).toBe(true); + }); + + it('handles very small positive decimal', () => { + const result = validateTradeQuantity('0.01', 'buy', 10); + expect(result.valid).toBe(true); + }); + + it('handles very large number', () => { + const result = validateTradeQuantity('999999999', 'buy', 10); + expect(result.valid).toBe(true); + }); + }); + + describe('formatValidationError', () => { + it('formats empty error', () => { + const message = formatValidationError('empty'); + expect(message).toBe('Please enter an amount.'); + }); + + it('formats invalid-number error', () => { + const message = formatValidationError('invalid-number'); + expect(message).toBe('Amount must be a valid number.'); + }); + + it('formats zero-or-negative error', () => { + const message = formatValidationError('zero-or-negative'); + expect(message).toBe('Amount must be greater than zero.'); + }); + + it('formats insufficient-balance error without holdings', () => { + const message = formatValidationError('insufficient-balance'); + expect(message).toContain('0 keys'); + }); + + it('formats insufficient-balance error with holdings', () => { + const message = formatValidationError('insufficient-balance', 42); + expect(message).toContain('42 keys'); + }); + + it('handles unknown error gracefully', () => { + const message = formatValidationError( + 'unknown-error' as TradeValidationError + ); + expect(message).toBe('Invalid amount.'); + }); + }); + + describe('Integration: Common User Input Patterns', () => { + it('handles user typing price instead of quantity', () => { + const result = validateTradeQuantity('0.05', 'buy', 10); + expect(result.valid).toBe(true); // Parses as valid number + }); + + it('rejects user pasting currency symbol', () => { + const result = validateTradeQuantity('$5', 'buy', 10); + expect(result.valid).toBe(false); + expect(result.error).toBe('invalid-number'); + }); + + it('handles user adding comma as thousands separator', () => { + const result = validateTradeQuantity('1,000', 'buy', 10); + expect(result.valid).toBe(false); + expect(result.error).toBe('invalid-number'); + }); + + it('handles user pressing delete key to clear field', () => { + const result = validateTradeQuantity('', 'sell', 10); + expect(result.valid).toBe(false); + expect(result.error).toBe('empty'); + }); + + it('user tries to sell 0.5 keys with 1 holding', () => { + const result = validateTradeQuantity('0.5', 'sell', 1); + expect(result.valid).toBe(true); + }); + + it('user mistakenly enters negative to indicate sell (buy side)', () => { + const result = validateTradeQuantity('-5', 'buy', 10); + expect(result.valid).toBe(false); + expect(result.error).toBe('zero-or-negative'); + }); + }); + + describe('Result Structure', () => { + it('valid result has no error or message', () => { + const result = validateTradeQuantity('5', 'buy', 10); + expect(result.valid).toBe(true); + expect(result.error).toBeUndefined(); + expect(result.message).toBeUndefined(); + }); + + it('invalid result includes error code and message', () => { + const result = validateTradeQuantity('abc', 'buy', 10); + expect(result.valid).toBe(false); + expect(result.error).toBeDefined(); + expect(result.message).toBeDefined(); + }); + }); +}); diff --git a/src/utils/__tests__/unhandledRejectionLogger.test.ts b/src/utils/__tests__/unhandledRejectionLogger.test.ts new file mode 100644 index 00000000..4250006c --- /dev/null +++ b/src/utils/__tests__/unhandledRejectionLogger.test.ts @@ -0,0 +1,117 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + __resetUnhandledRejectionLogger, + registerUnhandledRejectionLogger, +} from '@/utils/unhandledRejectionLogger'; + +function dispatchRejection(reason: unknown) { + const preventDefault = vi.fn(); + const event = { + reason, + promise: Promise.resolve(), + preventDefault, + } as unknown as PromiseRejectionEvent; + + window.onunhandledrejection?.call(window, event); + return { preventDefault }; +} + +describe('unhandledRejectionLogger (#647)', () => { + let debugSpy: ReturnType; + + beforeEach(() => { + __resetUnhandledRejectionLogger(); + debugSpy = vi.spyOn(console, 'debug').mockImplementation(() => {}); + }); + + afterEach(() => { + debugSpy.mockRestore(); + __resetUnhandledRejectionLogger(); + }); + + it('registers a window.onunhandledrejection handler', () => { + expect(window.onunhandledrejection).toBeNull(); + registerUnhandledRejectionLogger({ isTestEnv: false }); + expect(typeof window.onunhandledrejection).toBe('function'); + }); + + it('registers only once — later calls do not replace the handler', () => { + registerUnhandledRejectionLogger({ isTestEnv: false }); + const firstHandler = window.onunhandledrejection; + + registerUnhandledRejectionLogger({ isTestEnv: false }); + expect(window.onunhandledrejection).toBe(firstHandler); + + dispatchRejection(new Error('boom')); + expect(debugSpy).toHaveBeenCalledTimes(1); + }); + + it('emits a structured log with reason, promise_origin and rejected_at', () => { + registerUnhandledRejectionLogger({ isTestEnv: false }); + + dispatchRejection(new Error('payment fetch failed')); + + expect(debugSpy).toHaveBeenCalledWith( + '[unhandled-rejection]', + expect.objectContaining({ + reason: 'Error: payment fetch failed', + promise_origin: expect.any(String), + rejected_at: expect.stringMatching( + /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/ + ), + }) + ); + }); + + it('derives promise_origin from the error stack when available', () => { + registerUnhandledRejectionLogger({ isTestEnv: false }); + + dispatchRejection(new Error('with stack')); + + const [, log] = debugSpy.mock.calls[0]; + expect((log as { promise_origin: string }).promise_origin).not.toBe( + 'unknown' + ); + }); + + it('handles non-Error rejection reasons', () => { + registerUnhandledRejectionLogger({ isTestEnv: false }); + + dispatchRejection('plain string reason'); + + expect(debugSpy).toHaveBeenCalledWith( + '[unhandled-rejection]', + expect.objectContaining({ + reason: 'plain string reason', + promise_origin: 'unknown', + }) + ); + }); + + it('does not suppress default browser behaviour', () => { + registerUnhandledRejectionLogger({ isTestEnv: false }); + + const { preventDefault } = dispatchRejection(new Error('boom')); + + expect(preventDefault).not.toHaveBeenCalled(); + }); + + it('emits nothing in the test environment (default detection)', () => { + // Vitest sets import.meta.env.MODE to 'test', so the default + // registration path must stay silent. + registerUnhandledRejectionLogger(); + + dispatchRejection(new Error('should not be logged')); + + expect(debugSpy).not.toHaveBeenCalled(); + }); + + it('emits one log per rejection', () => { + registerUnhandledRejectionLogger({ isTestEnv: false }); + + dispatchRejection(new Error('first')); + dispatchRejection(new Error('second')); + + expect(debugSpy).toHaveBeenCalledTimes(2); + }); +}); diff --git a/src/utils/__tests__/walletConnection.utils.test.ts b/src/utils/__tests__/walletConnection.utils.test.ts new file mode 100644 index 00000000..a1fe2587 --- /dev/null +++ b/src/utils/__tests__/walletConnection.utils.test.ts @@ -0,0 +1,41 @@ +import { describe, it, expect } from 'vitest'; +import { isOwnWallet } from '../walletConnection.utils'; + +describe('walletConnection.utils', () => { + describe('isOwnWallet', () => { + it('returns true for matching addresses', () => { + const address = '0x1234567890abcdef1234567890abcdef12345678'; + const connectedAddress = '0x1234567890abcdef1234567890abcdef12345678'; + expect(isOwnWallet(address, connectedAddress)).toBe(true); + }); + + it('returns false for non-matching addresses', () => { + const address = '0x1234567890abcdef1234567890abcdef12345678'; + const connectedAddress = '0xabcdef1234567890abcdef1234567890abcdef12'; + expect(isOwnWallet(address, connectedAddress)).toBe(false); + }); + + it('returns false when connected address is null', () => { + const address = '0x1234567890abcdef1234567890abcdef12345678'; + expect(isOwnWallet(address, null)).toBe(false); + }); + + it('performs case-insensitive comparison', () => { + const address = '0x1234567890ABCDEF1234567890ABCDEF12345678'; + const connectedAddress = '0x1234567890abcdef1234567890abcdef12345678'; + expect(isOwnWallet(address, connectedAddress)).toBe(true); + }); + + it('handles mixed casing correctly', () => { + const address = '0x1234567890AbCdEf1234567890AbCdEf12345678'; + const connectedAddress = '0x1234567890aBcDeF1234567890aBcDeF12345678'; + expect(isOwnWallet(address, connectedAddress)).toBe(true); + }); + + it('returns false for different addresses with different casing', () => { + const address = '0x1234567890ABCDEF1234567890ABCDEF12345678'; + const connectedAddress = '0xabcdef1234567890abcdef1234567890abcdef12'; + expect(isOwnWallet(address, connectedAddress)).toBe(false); + }); + }); +}); diff --git a/src/utils/activityTimeline.utils.ts b/src/utils/activityTimeline.utils.ts index 6102ca1f..7c09c492 100644 --- a/src/utils/activityTimeline.utils.ts +++ b/src/utils/activityTimeline.utils.ts @@ -1,3 +1,5 @@ +import { formatKeyPrice } from '@/utils/keyPriceDisplay.utils'; + interface TimelineEntryWithTimestamp { timestamp?: number; } @@ -35,3 +37,16 @@ export const formatDateHeader = (date: Date) => { }); } }; + +/** + * Formats an activity amount (buy/sell) with a sign prefix and XLM suffix. + * Buys are prefixed with '-', sells with '+'. + */ +export function formatActivityAmount(amount: bigint, type: 'buy' | 'sell'): string { + if (amount === 0n) { + return '+0.00 XLM'; + } + const sign = type === 'buy' ? '-' : '+'; + const formatted = formatKeyPrice(amount); + return `${sign}${formatted}`; +} diff --git a/src/utils/bondingCurve.utils.ts b/src/utils/bondingCurve.utils.ts new file mode 100644 index 00000000..697c69fc --- /dev/null +++ b/src/utils/bondingCurve.utils.ts @@ -0,0 +1,142 @@ +import { STROOPS_PER_XLM } from '@/constants/stellar'; + +/** + * Bonding curve parameters for price calculation. + * These parameters define the shape of the bonding curve. + */ +export interface BondingCurveParams { + /** Base price in stroops when supply is 0 */ + basePriceStroops: number; + /** Growth factor for exponential bonding curve (e.g., 1.01 for 1% growth per key) */ + growthFactor: number; +} + +/** + * Computes the bonding curve price at a given supply step. + * Uses an exponential bonding curve formula: price = base_price * (growth_factor ^ supply) + * + * This is a pure computation function that does not mutate contract state. + * It's useful for: + * - Previewing prices before transactions + * - Displaying price charts + * - Calculating expected costs + * + * @param supply - The current supply (number of keys minted) + * @param params - Bonding curve parameters + * @returns Price in stroops at the given supply step + * + * @example + * ```ts + * const params = { basePriceStroops: 10000000, growthFactor: 1.01 }; // 1 XLM base, 1% growth + * const priceAtSupply10 = computeBondingCurvePrice(10, params); + * console.log(priceAtSupply10); // Price after 10 keys have been minted + * ``` + */ +export function computeBondingCurvePrice( + supply: number, + params: BondingCurveParams +): number { + if (supply < 0) { + throw new Error('Supply cannot be negative'); + } + if (params.basePriceStroops < 0) { + throw new Error('Base price cannot be negative'); + } + if (params.growthFactor <= 0) { + throw new Error('Growth factor must be positive'); + } + + // Linear bonding curve: price = base_price * (1 + (growth_factor - 1) * supply) + // This is equivalent to base_price * growth_factor^supply for small growth factors + // but more numerically stable for large supplies + const priceMultiplier = 1 + (params.growthFactor - 1) * supply; + return params.basePriceStroops * priceMultiplier; +} + +/** + * Computes the bonding curve price in XLM (decimal) at a given supply step. + * Convenience wrapper around computeBondingCurvePrice that converts stroops to XLM. + * + * @param supply - The current supply (number of keys minted) + * @param params - Bonding curve parameters + * @returns Price in XLM at the given supply step + */ +export function computeBondingCurvePriceXLM( + supply: number, + params: BondingCurveParams +): number { + const priceStroops = computeBondingCurvePrice(supply, params); + return priceStroops / STROOPS_PER_XLM; +} + +/** + * Computes the total cost to buy a quantity of keys from a given supply. + * This calculates the area under the bonding curve from `supply` to `supply + quantity`. + * + * For a linear bonding curve, this is the integral: + * total_cost = base_price * quantity * (1 + (growth_factor - 1) * (supply + quantity/2)) + * + * @param currentSupply - Current supply before purchase + * @param quantity - Number of keys to buy + * @param params - Bonding curve parameters + * @returns Total cost in stroops + */ +export function computeBuyCost( + currentSupply: number, + quantity: number, + params: BondingCurveParams +): number { + if (quantity < 0) { + throw new Error('Quantity cannot be negative'); + } + if (currentSupply < 0) { + throw new Error('Current supply cannot be negative'); + } + + const startPrice = computeBondingCurvePrice(currentSupply, params); + const endPrice = computeBondingCurvePrice(currentSupply + quantity, params); + + // Average price for linear bonding curve + const avgPrice = (startPrice + endPrice) / 2; + return avgPrice * quantity; +} + +/** + * Computes the total revenue from selling a quantity of keys at a given supply. + * This is the reverse of computeBuyCost - calculates the area under the curve + * from `supply - quantity` to `supply`. + * + * @param currentSupply - Current supply before sale + * @param quantity - Number of keys to sell + * @param params - Bonding curve parameters + * @returns Total revenue in stroops + */ +export function computeSellRevenue( + currentSupply: number, + quantity: number, + params: BondingCurveParams +): number { + if (quantity < 0) { + throw new Error('Quantity cannot be negative'); + } + if (quantity > currentSupply) { + throw new Error('Cannot sell more keys than current supply'); + } + + const newSupply = currentSupply - quantity; + const startPrice = computeBondingCurvePrice(newSupply, params); + const endPrice = computeBondingCurvePrice(currentSupply, params); + + // Average price for linear bonding curve + const avgPrice = (startPrice + endPrice) / 2; + return avgPrice * quantity; +} + +/** + * Default bonding curve parameters for the platform. + * These can be overridden per creator if needed. + */ +export const DEFAULT_BONDING_CURVE_PARAMS: BondingCurveParams = { + basePriceStroops: 10_000_000, // 1 XLM + growthFactor: 1.01, // 1% growth per key +}; diff --git a/src/utils/clipboard.utils.ts b/src/utils/clipboard.utils.ts new file mode 100644 index 00000000..de824900 --- /dev/null +++ b/src/utils/clipboard.utils.ts @@ -0,0 +1,68 @@ +const createClipboardUnavailableError = () => + new Error('Clipboard API is unavailable in this environment.'); + +const copyWithExecCommandFallback = (text: string): boolean => { + if ( + typeof document === 'undefined' || + !document.body || + typeof document.execCommand !== 'function' + ) { + return false; + } + + const activeElement = + document.activeElement instanceof HTMLElement + ? document.activeElement + : null; + const textArea = document.createElement('textarea'); + + textArea.value = text; + textArea.setAttribute('readonly', ''); + textArea.setAttribute('aria-hidden', 'true'); + textArea.style.position = 'fixed'; + textArea.style.top = '0'; + textArea.style.left = '0'; + textArea.style.opacity = '0'; + textArea.style.pointerEvents = 'none'; + + document.body.appendChild(textArea); + textArea.focus(); + textArea.select(); + textArea.setSelectionRange(0, text.length); + + try { + return document.execCommand('copy'); + } finally { + document.body.removeChild(textArea); + activeElement?.focus(); + } +}; + +export const copyTextToClipboard = async (text: string): Promise => { + let originalError: unknown; + + try { + if (typeof navigator === 'undefined' || !navigator.clipboard?.writeText) { + throw createClipboardUnavailableError(); + } + + await navigator.clipboard.writeText(text); + return; + } catch (error) { + originalError = error; + console.error( + 'Clipboard API copy failed. Attempting execCommand fallback.', + error + ); + } + + if (copyWithExecCommandFallback(text)) { + return; + } + + if (originalError instanceof Error) { + throw originalError; + } + + throw new Error('Copy to clipboard failed.'); +}; diff --git a/src/utils/creatorListKey.utils.ts b/src/utils/creatorListKey.utils.ts new file mode 100644 index 00000000..a06c3f8a --- /dev/null +++ b/src/utils/creatorListKey.utils.ts @@ -0,0 +1,5 @@ +export const creatorListKey = (creatorId: number | string): string => { + const str = String(creatorId); + return str.startsWith('creator-') ? str : `creator-${str}`; +}; + diff --git a/src/utils/env.utils.ts b/src/utils/env.utils.ts index 4d7a3387..0a91fb81 100644 --- a/src/utils/env.utils.ts +++ b/src/utils/env.utils.ts @@ -7,6 +7,7 @@ const envSchema = z.object({ VITE_BASE_SEPOLIA_RPC_URL: z.string().default('https://sepolia.base.org'), VITE_SEPOLIA_RPC_URL: z.string().optional(), VITE_MAINNET_RPC_URL: z.string().optional(), + VITE_STELLAR_NETWORK: z.enum(['mainnet', 'testnet']).default('testnet'), // UTM configuration for share links. Optional — when not provided, share URLs remain unchanged. VITE_UTM_SOURCE: z.string().optional(), VITE_UTM_MEDIUM: z.string().optional(), @@ -22,6 +23,7 @@ export const env = envSchema.parse({ VITE_BASE_SEPOLIA_RPC_URL: import.meta.env.VITE_BASE_SEPOLIA_RPC_URL, VITE_SEPOLIA_RPC_URL: import.meta.env.VITE_SEPOLIA_RPC_URL, VITE_MAINNET_RPC_URL: import.meta.env.VITE_MAINNET_RPC_URL, + VITE_STELLAR_NETWORK: import.meta.env.VITE_STELLAR_NETWORK, VITE_UTM_SOURCE: import.meta.env.VITE_UTM_SOURCE, VITE_UTM_MEDIUM: import.meta.env.VITE_UTM_MEDIUM, VITE_UTM_CAMPAIGN: import.meta.env.VITE_UTM_CAMPAIGN, diff --git a/src/utils/globalErrorHandler.utils.ts b/src/utils/globalErrorHandler.utils.ts new file mode 100644 index 00000000..fd8f902c --- /dev/null +++ b/src/utils/globalErrorHandler.utils.ts @@ -0,0 +1,126 @@ +/** + * Global error handler utilities to prevent duplicate error logging + * between React error boundaries and window.onerror. + * + * Architecture: + * - React error boundaries call `markErrorAsCaught(error)` in componentDidCatch + * - The global `window.onerror` handler checks if the error was already caught + * - If caught, the handler silently returns true (preventing default browser handling) + * - If not caught, it emits a structured log for the genuine unhandled error + * - Duplicate identity detection prevents the same error from being logged more than once + */ + +interface StructuredErrorLog { + message: string | Event; + source?: string; + lineno?: number; + colno?: number; + error?: Error; + timestamp: number; +} + +/** Generate a unique identity for an error based on its message and stack. */ +function getErrorIdentity(error: Error): string { + return `${error.message}::${error.stack ?? ''}`; +} + +/** Track error identities already caught by React error boundaries. */ +const caughtErrorIds = new Set(); + +/** Track error identities already processed by the global handler (prevents duplicates). */ +const processedErrorIds = new Set(); + +/** + * Register an error as already caught by a React error boundary. + * The global error handler will skip this error to avoid duplicate logs. + */ +export function markErrorAsCaught(error: Error): void { + caughtErrorIds.add(getErrorIdentity(error)); +} + +/** + * Check if an error was already caught by a React error boundary. + */ +export function wasErrorAlreadyCaught(error: Error): boolean { + return caughtErrorIds.has(getErrorIdentity(error)); +} + +/** + * Emit a structured error log with all relevant metadata. + * Testable in isolation by spying on console.error. + */ +export function emitStructuredLog(log: StructuredErrorLog): void { + console.error('[Global Error Handler]', { + message: log.message, + source: log.source, + line: log.lineno, + column: log.colno, + error: log.error + ? { + name: log.error.name, + message: log.error.message, + stack: log.error.stack, + } + : null, + timestamp: log.timestamp, + }); +} + +/** + * Handle a global error event. + * - If the error was already caught by a React error boundary, skip it and return true + * - If the error is a duplicate (same identity already processed), skip it and return true + * - Otherwise, emit a structured log for the genuine unhandled error + * + * @returns true to prevent default browser error handling, false otherwise. + */ +export function handleGlobalError( + message: string | Event, + source?: string, + lineno?: number, + colno?: number, + error?: Error +): boolean { + // Skip errors already caught by React error boundaries + if (error && wasErrorAlreadyCaught(error)) { + return true; + } + + // Skip duplicate errors (same identity already processed) + if (error) { + const identity = getErrorIdentity(error); + if (processedErrorIds.has(identity)) { + return true; + } + processedErrorIds.add(identity); + } + + // Emit structured log for genuine unhandled errors + emitStructuredLog({ + message, + source, + lineno, + colno, + error, + timestamp: Date.now(), + }); + + return false; +} + +/** + * Initialize the global error handler on window.onerror. + * Should be called once at application startup (e.g., in main.tsx). + */ +export function initGlobalErrorHandler(): void { + window.onerror = handleGlobalError; +} + +/** + * Clear all tracked error identities. + * Useful in tests to reset state between test cases. + */ +export function resetErrorTracking(): void { + caughtErrorIds.clear(); + processedErrorIds.clear(); +} diff --git a/src/utils/handleDisplay.utils.ts b/src/utils/handleDisplay.utils.ts index 4d5ebefc..7989e7a2 100644 --- a/src/utils/handleDisplay.utils.ts +++ b/src/utils/handleDisplay.utils.ts @@ -19,8 +19,25 @@ export const formatCreatorHandle = (raw: string | null | undefined): string => { if (raw == null) return ''; const trimmed = raw.trim(); if (trimmed === '') return ''; - const withoutLeadingAt = trimmed.startsWith('@') ? trimmed.slice(1) : trimmed; + const withoutLeadingAt = trimmed.startsWith('@') + ? trimmed.slice(1) + : trimmed; const normalised = withoutLeadingAt.trim().toLowerCase(); if (normalised === '') return ''; return `@${normalised}`; }; + +/** + * Truncates a creator handle to a maximum length, appending an ellipsis + * only when truncation occurs. + */ +export const truncateHandle = ( + handle: string, + maxLength: number = 20 +): string => { + if (!handle) return ''; + if (handle.length <= maxLength) { + return handle; + } + return `${handle.slice(0, maxLength)}...`; +}; diff --git a/src/utils/holderCount.utils.ts b/src/utils/holderCount.utils.ts new file mode 100644 index 00000000..4f35ca96 --- /dev/null +++ b/src/utils/holderCount.utils.ts @@ -0,0 +1,28 @@ +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.', + }; +} diff --git a/src/utils/isOwnWallet.ts b/src/utils/isOwnWallet.ts new file mode 100644 index 00000000..31edad71 --- /dev/null +++ b/src/utils/isOwnWallet.ts @@ -0,0 +1,15 @@ +/** + * Returns true when the connected wallet address matches the creator's + * address. Both arguments are compared case-insensitively so that + * mixed-case Stellar / EVM addresses are handled correctly. + * + * Returns false when either address is missing — callers should never + * see an "own wallet" state unless both sides are present. + */ +export function isOwnWallet( + connectedAddress: string | null | undefined, + creatorAddress: string | null | undefined +): boolean { + if (!connectedAddress || !creatorAddress) return false; + return connectedAddress.toLowerCase() === creatorAddress.toLowerCase(); +} diff --git a/src/utils/keyHolderRanking.utils.ts b/src/utils/keyHolderRanking.utils.ts new file mode 100644 index 00000000..ac95a84c --- /dev/null +++ b/src/utils/keyHolderRanking.utils.ts @@ -0,0 +1,40 @@ +export interface KeyHolder { + id: string; + displayName: string; + keyCount: number; +} + +export interface RankedKeyHolder extends KeyHolder { + rank: number; + sharePercent: number; +} + +/** + * Ranks holders descending by key count and computes each holder's share of + * the total supply held across the list. + * + * Ranks are competition-style ("1224"): holders tied on key count share the + * same rank, and the next distinct count resumes at its 1-indexed position + * rather than incrementing by 1 — e.g. two holders tied for 1st both get + * rank 1, and the following holder gets rank 3, not rank 2. + */ +export function rankKeyHolders(holders: KeyHolder[]): RankedKeyHolder[] { + const sorted = [...holders].sort((a, b) => b.keyCount - a.keyCount); + const totalKeys = sorted.reduce((sum, holder) => sum + holder.keyCount, 0); + + let currentRank = 0; + let previousKeyCount: number | null = null; + + return sorted.map((holder, index) => { + if (holder.keyCount !== previousKeyCount) { + currentRank = index + 1; + previousKeyCount = holder.keyCount; + } + + return { + ...holder, + rank: currentRank, + sharePercent: totalKeys > 0 ? (holder.keyCount / totalKeys) * 100 : 0, + }; + }); +} diff --git a/src/utils/keyPriceDisplay.utils.ts b/src/utils/keyPriceDisplay.utils.ts index 3b0acd1e..e8895fa5 100644 --- a/src/utils/keyPriceDisplay.utils.ts +++ b/src/utils/keyPriceDisplay.utils.ts @@ -83,3 +83,22 @@ export function formatCreatorKeyPriceDisplay( ): string { return formatDisplayKeyPrice(resolveCreatorKeyPriceStroops(creator)); } + +/** + * Formats a key price in stroops (bigint) to XLM with proper decimal precision. + * Always displays 2 decimal places for prices >= 1 XLM, and 4 decimal places for prices < 1 XLM. + */ +export function formatKeyPrice(stroops: bigint): string { + const STROOPS_PER_XLM_BI = 10_000_000n; + const isBelowOneXlm = stroops < STROOPS_PER_XLM_BI; + const decimals = isBelowOneXlm ? 4 : 2; + + const xlm = Number(stroops) / 10_000_000; + const formattedValue = formatNumber(xlm, { + minimumFractionDigits: decimals, + maximumFractionDigits: decimals, + }); + + return `${formattedValue} XLM`; +} + diff --git a/src/utils/lineClamp.utils.ts b/src/utils/lineClamp.utils.ts index f7f3c1c7..37ec7a23 100644 --- a/src/utils/lineClamp.utils.ts +++ b/src/utils/lineClamp.utils.ts @@ -30,3 +30,18 @@ export const lineClampClassFor = ( return 'line-clamp-6'; } }; + +/** + * Default line clamp count for creator card subtitles and bio descriptions. + */ +export const DEFAULT_CREATOR_CARD_SUBTITLE_MAX_LINES = 2; + +/** + * Returns the Tailwind `line-clamp` class for creator card subtitles and bio descriptions. + * Uses `DEFAULT_CREATOR_CARD_SUBTITLE_MAX_LINES` (2 lines) if maxLines is omitted. + */ +export const creatorCardSubtitleClampClass = ( + maxLines: number | null | undefined = DEFAULT_CREATOR_CARD_SUBTITLE_MAX_LINES +): string => { + return lineClampClassFor('card', maxLines); +}; diff --git a/src/utils/logger.ts b/src/utils/logger.ts new file mode 100644 index 00000000..559e397a --- /dev/null +++ b/src/utils/logger.ts @@ -0,0 +1,24 @@ +/** + * Lightweight structured logger. + * + * Emits `console.debug` in development. Suppressed entirely in the test + * environment (`import.meta.env.MODE === 'test'`) so test output stays + * clean and assertions on `console.debug` remain unambiguous. + */ + +type LogFields = Record; + +const isTestEnv = import.meta.env.MODE === 'test'; + +export const logger = { + /** + * Emit a structured debug-level log. No-op in the test environment. + * + * @param message - Human-readable description of the event. + * @param fields - Key/value pairs to attach to the log entry. + */ + debug(message: string, fields?: LogFields): void { + if (isTestEnv) return; + console.debug('[debug]', message, fields ?? {}); + }, +}; diff --git a/src/utils/numberFormat.utils.ts b/src/utils/numberFormat.utils.ts index ea6eccab..cee835c7 100644 --- a/src/utils/numberFormat.utils.ts +++ b/src/utils/numberFormat.utils.ts @@ -45,7 +45,14 @@ export function formatCompactNumber( return formatNumber(value, { ...options, style: 'compact' }); } -export function formatFollowerCount(count: number): string { +/** + * Formats holder counts for compact display across creator profile surfaces. + * + * - Below 1,000: plain string (e.g. `999`) + * - 1,000–999,999: one decimal K suffix (e.g. `1.2K`, `1K` at exactly 1,000) + * - 1,000,000+: one decimal M suffix (e.g. `2.4M`, `1M` at exactly 1,000,000) + */ +export function formatHolderCount(count: number): string { if (count >= 1_000_000) { return `${(count / 1_000_000).toFixed(1).replace(/\.0$/, '')}M`; } @@ -55,6 +62,10 @@ export function formatFollowerCount(count: number): string { return count.toString(); } +export function formatFollowerCount(count: number): string { + return formatHolderCount(count); +} + export interface FormatPercentOptions { /** Maximum fractional digits in the rendered value. Defaults to 2. */ maximumFractionDigits?: number; @@ -102,3 +113,15 @@ export function formatPercent( return `${sign}${formatted}%`; } +/** + * Converts basis points (bps) to a percentage string (e.g. 500 -> "5%", 250 -> "2.5%"). + */ +export function bpsToPercent( + bps: number | null | undefined, + options: FormatPercentOptions = {} +): string { + if (bps == null || !Number.isFinite(bps)) { + return options.emptyPlaceholder ?? '—'; + } + return formatPercent(bps / 100, options); +} diff --git a/src/utils/portfolioValue.utils.ts b/src/utils/portfolioValue.utils.ts index 07f1d9f0..1676d709 100644 --- a/src/utils/portfolioValue.utils.ts +++ b/src/utils/portfolioValue.utils.ts @@ -9,6 +9,7 @@ export interface HeldKeyPosition extends CreatorKeyPriceFields { quantity: number | null | undefined; isPriceLoading?: boolean; isPriceStale?: boolean; + pending?: boolean; } export type PortfolioValueStatus = 'ready' | 'loading' | 'unavailable'; diff --git a/src/utils/preferences.utils.ts b/src/utils/preferences.utils.ts new file mode 100644 index 00000000..c8c0d23d --- /dev/null +++ b/src/utils/preferences.utils.ts @@ -0,0 +1,36 @@ +/** + * Retrieves a parsed value from localStorage. + * Handles missing keys and malformed JSON safely by returning the defaultValue. + * + * @param key The localStorage key to retrieve + * @param defaultValue The default value to return if the key is missing or parsing fails + * @returns The parsed value or the default value + */ +export function getPreference(key: string, defaultValue: T): T { + try { + const item = window.localStorage.getItem(key); + if (item === null) { + return defaultValue; + } + return JSON.parse(item) as T; + } catch (error) { + console.warn(`Error reading localStorage key "${key}":`, error); + return defaultValue; + } +} + +/** + * Serializes and stores a value in localStorage. + * Safely handles potential storage errors (e.g., quota exceeded). + * + * @param key The localStorage key to set + * @param value The value to serialize and store + */ +export function setPreference(key: string, value: T): void { + try { + const serialized = JSON.stringify(value); + window.localStorage.setItem(key, serialized); + } catch (error) { + console.warn(`Error setting localStorage key "${key}":`, error); + } +} diff --git a/src/utils/priceChange.utils.ts b/src/utils/priceChange.utils.ts new file mode 100644 index 00000000..77e12f1c --- /dev/null +++ b/src/utils/priceChange.utils.ts @@ -0,0 +1,46 @@ +// src/utils/priceChange.utils.ts + +/** + * Result of a price change computation between two key price values. + */ +export interface PriceChangeResult { + /** Percentage change as a number, e.g. 12.4 or -3.1. Always 0 when direction is 'flat'. */ + percent: number; + /** Direction of the price movement relative to the previous value. */ + direction: 'up' | 'down' | 'flat'; +} + +/** + * Computes the percentage price change between two key price values expressed + * in stroops (bigint). + * + * Returns `flat` with `percent: 0` when: + * - `previous` is zero (division by zero is undefined) + * - `current` equals `previous` (no change) + * + * @param current - The current key price in stroops + * @param previous - The previous key price in stroops + * @returns PriceChangeResult with percent and direction + * + * @example + * computePriceChange(112n, 100n) // { percent: 12, direction: 'up' } + * computePriceChange(88n, 100n) // { percent: -12, direction: 'down' } + * computePriceChange(100n, 100n) // { percent: 0, direction: 'flat' } + * computePriceChange(100n, 0n) // { percent: 0, direction: 'flat' } + */ +export function computePriceChange( + current: bigint, + previous: bigint +): PriceChangeResult { + if (previous === 0n || current === previous) { + return { percent: 0, direction: 'flat' }; + } + + const percent = + (Number(current - previous) / Number(previous)) * 100; + + return { + percent, + direction: percent > 0 ? 'up' : 'down', + }; +} \ No newline at end of file diff --git a/src/utils/stellarLedger.utils.ts b/src/utils/stellarLedger.utils.ts new file mode 100644 index 00000000..a256971c --- /dev/null +++ b/src/utils/stellarLedger.utils.ts @@ -0,0 +1,20 @@ +/** + * Converts a Stellar ledger sequence number to an estimated UTC timestamp. + * Uses Stellar's ~5 second ledger time for estimation. + * + * @param ledger - The ledger sequence number to convert + * @param referenceLedger - A known ledger sequence number with a known timestamp + * @param referenceTimestamp - The timestamp (in ms) corresponding to the reference ledger + * @returns Estimated Date for the target ledger + */ +export function ledgerToTimestamp( + ledger: number, + referenceLedger: number, + referenceTimestamp: number +): Date { + const LEDGER_TIME_MS = 5000; // 5 seconds per ledger in milliseconds + const ledgerDelta = ledger - referenceLedger; + const timeDelta = ledgerDelta * LEDGER_TIME_MS; + const estimatedTimestamp = referenceTimestamp + timeDelta; + return new Date(estimatedTimestamp); +} diff --git a/src/utils/time.utils.ts b/src/utils/time.utils.ts index 6fce6c4b..18eae151 100644 --- a/src/utils/time.utils.ts +++ b/src/utils/time.utils.ts @@ -81,6 +81,24 @@ export function formatAbsoluteDateTime( }).format(date); } +export function formatRelativeTimeLabel(date: Date, now: Date = new Date()): string { + const diffMs = now.getTime() - date.getTime(); + const diffSec = Math.floor(diffMs / 1000); + + if (diffSec < 60) return 'just now'; + + const diffMin = Math.floor(diffSec / 60); + if (diffMin < 60) return `${diffMin} minutes ago`; + + const diffHr = Math.floor(diffMin / 60); + if (diffHr < 24) return `${diffHr} hours ago`; + + const diffDay = Math.floor(diffHr / 24); + if (diffDay < 30) return `${diffDay} days ago`; + + return date.toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' }); +} + export function formatRelativeTime( input: string | number | Date | null | undefined, options: RelativeTimeOptions = {} diff --git a/src/utils/toast.util.tsx b/src/utils/toast.util.tsx index 29d8d643..bcaa5d1c 100644 --- a/src/utils/toast.util.tsx +++ b/src/utils/toast.util.tsx @@ -2,6 +2,8 @@ import toast from 'react-hot-toast'; import type { ToastOptions } from 'react-hot-toast'; import TransactionHashRow from '@/components/common/TransactionHashRow'; +const TRANSACTION_TOAST_DURATION_MS = 6_000; + const showToast = { message: (message: string, options?: ToastOptions) => { toast.remove(); @@ -53,10 +55,20 @@ const showToast = { className="mt-1 bg-white/5 rounded-lg px-2.5 py-1.5" /> )} + {explorerUrl && ( +
+ View on Stellar Expert + + )}
), - { duration: 4000 } + { duration: TRANSACTION_TOAST_DURATION_MS } ); }, }; diff --git a/src/utils/tradeQuantityValidation.ts b/src/utils/tradeQuantityValidation.ts new file mode 100644 index 00000000..3c5dc995 --- /dev/null +++ b/src/utils/tradeQuantityValidation.ts @@ -0,0 +1,96 @@ +/** + * Trade panel quantity input validation utilities. + * Validates user input for buy/sell trade quantities. + */ + +export type TradeValidationError = + | 'empty' + | 'invalid-number' + | 'zero-or-negative' + | 'insufficient-balance'; + +export interface ValidationResult { + valid: boolean; + error?: TradeValidationError; + message?: string; +} + +/** + * Validates a trade quantity string. + * + * @param input - Raw user input (may contain whitespace, non-numeric chars, etc.) + * @param side - Trade type: 'buy' or 'sell' + * @param availableHoldings - Current holdings (used for sell validation) + * @returns ValidationResult with error code and message if invalid + */ +export function validateTradeQuantity( + input: string, + side: 'buy' | 'sell', + availableHoldings: number +): ValidationResult { + const normalized = input.trim(); + + // Empty input + if (!normalized) { + return { + valid: false, + error: 'empty', + message: 'Please enter an amount.', + }; + } + + // Try to parse as number + const parsed = Number(normalized); + + // Non-numeric input + if (!Number.isFinite(parsed)) { + return { + valid: false, + error: 'invalid-number', + message: 'Amount must be a valid number.', + }; + } + + // Zero or negative value + if (parsed <= 0) { + return { + valid: false, + error: 'zero-or-negative', + message: 'Amount must be greater than zero.', + }; + } + + // Sell: check balance + if (side === 'sell' && parsed > availableHoldings) { + return { + valid: false, + error: 'insufficient-balance', + message: `You can't sell more than your holdings (${Math.floor(availableHoldings)} keys).`, + }; + } + + // Valid + return { valid: true }; +} + +/** + * Formats a validation error code into a user-friendly message. + * Used when you have just the error code but need the message. + */ +export function formatValidationError( + error: TradeValidationError, + availableHoldings?: number +): string { + switch (error) { + case 'empty': + return 'Please enter an amount.'; + case 'invalid-number': + return 'Amount must be a valid number.'; + case 'zero-or-negative': + return 'Amount must be greater than zero.'; + case 'insufficient-balance': + return `You can't sell more than your holdings (${availableHoldings ? Math.floor(availableHoldings) : 0} keys).`; + default: + return 'Invalid amount.'; + } +} diff --git a/src/utils/unhandledRejectionLogger.ts b/src/utils/unhandledRejectionLogger.ts new file mode 100644 index 00000000..2962baf6 --- /dev/null +++ b/src/utils/unhandledRejectionLogger.ts @@ -0,0 +1,88 @@ +/** + * Structured logging for unhandled promise rejections. + * + * Fire-and-forget async calls that live outside React (event handlers, + * module-level warmup, detached timers) reject silently in production — + * the React error boundary never sees them because it only catches errors + * thrown during rendering, which is also why this handler cannot duplicate + * boundary logs: the two failure classes are disjoint. + * + * Registered once at app initialisation (module scope in `main.tsx`), not + * inside a component, so re-renders can never re-register it. + */ + +export interface UnhandledRejectionLog { + reason: string; + promise_origin: string; + rejected_at: string; +} + +interface RegisterOptions { + /** + * Suppresses log emission when true. Defaults to detecting Vitest via + * `import.meta.env.MODE === 'test'`; injectable so the logger itself + * can be tested. + */ + isTestEnv?: boolean; +} + +let registered = false; + +function describeReason(reason: unknown): string { + if (reason instanceof Error) { + return `${reason.name}: ${reason.message}`; + } + if (typeof reason === 'string') return reason; + try { + return JSON.stringify(reason); + } catch { + return String(reason); + } +} + +function describeOrigin(reason: unknown): string { + if (reason instanceof Error && reason.stack) { + // First stack frame below the error message — the rejection site + const frame = reason.stack + .split('\n') + .slice(1) + .map(line => line.trim()) + .find(line => line.length > 0); + if (frame) return frame; + } + return 'unknown'; +} + +/** + * Registers the `window.onunhandledrejection` handler. Safe to call more + * than once — only the first call installs the handler. + * + * The handler never calls `preventDefault()`, so the browser's default + * unhandled-rejection reporting is preserved. + */ +export function registerUnhandledRejectionLogger( + options: RegisterOptions = {} +): void { + const { isTestEnv = import.meta.env.MODE === 'test' } = options; + + if (registered) return; + registered = true; + + window.onunhandledrejection = event => { + if (isTestEnv) return; + + const log: UnhandledRejectionLog = { + reason: describeReason(event.reason), + promise_origin: describeOrigin(event.reason), + rejected_at: new Date().toISOString(), + }; + + console.debug('[unhandled-rejection]', log); + }; +} + +/** Test hook: unregister so each test starts from a clean slate. */ +export function __resetUnhandledRejectionLogger(): void { + registered = false; + window.onunhandledrejection = null; +} diff --git a/src/utils/walletConnection.utils.ts b/src/utils/walletConnection.utils.ts index 761c3c08..48aeba2f 100644 --- a/src/utils/walletConnection.utils.ts +++ b/src/utils/walletConnection.utils.ts @@ -21,7 +21,7 @@ export interface WalletReconnectOptions { /** * Determines if wallet session is stale based on connection time - * + * * @param lastConnected - Timestamp of last connection * @param staleThresholdMs - Time in ms before considering session stale (default: 1 hour) * @returns Whether the session is stale @@ -31,7 +31,7 @@ export function isSessionStale( staleThresholdMs: number = 60 * 60 * 1000 // 1 hour ): boolean { if (!lastConnected) return true; - + const now = Date.now(); const timeSinceConnection = now - lastConnected; return timeSinceConnection > staleThresholdMs; @@ -39,7 +39,7 @@ export function isSessionStale( /** * Gets the appropriate wallet connection status - * + * * @param state - Current wallet connection state * @param staleThresholdMs - Time threshold for staleness * @returns Connection status object @@ -59,7 +59,7 @@ export function getWalletConnectionStatus( return { isConnected: false, isStale: false, - status: 'disconnected' + status: 'disconnected', }; } @@ -67,20 +67,20 @@ export function getWalletConnectionStatus( return { isConnected: true, isStale: true, - status: 'stale' + status: 'stale', }; } return { isConnected: true, isStale: false, - status: 'connected' + status: 'connected', }; } /** * Generates helper text for wallet reconnection scenarios - * + * * @param state - Current wallet connection state * @param options - Display options * @returns Helper text and action information @@ -102,7 +102,7 @@ export function getWalletReconnectHelperText( } { const { walletName, showDetails = true } = options; const reconnectText = options.reconnectText || 'Reconnect'; - + const status = getWalletConnectionStatus(state); const displayName = walletName || state.walletName || 'your wallet'; @@ -111,11 +111,11 @@ export function getWalletReconnectHelperText( return { shouldShow: true, message: `${displayName} is not connected`, - details: showDetails + details: showDetails ? 'Connect your wallet to access creator features and make transactions.' : undefined, actionText: `Connect ${displayName}`, - severity: 'info' as const + severity: 'info' as const, }; } @@ -128,7 +128,7 @@ export function getWalletReconnectHelperText( ? 'Your wallet session timed out for security. Please reconnect to continue.' : undefined, actionText: reconnectText, - severity: 'warning' as const + severity: 'warning' as const, }; } @@ -137,13 +137,13 @@ export function getWalletReconnectHelperText( shouldShow: false, message: '', actionText: reconnectText, - severity: 'info' as const + severity: 'info' as const, }; } /** * Gets a short status message for compact UI displays - * + * * @param state - Current wallet connection state * @param options - Display options * @returns Short status message @@ -154,9 +154,9 @@ export function getWalletStatusMessage( ): string { const { walletName } = options; const displayName = walletName || state.walletName || 'Wallet'; - + const status = getWalletConnectionStatus(state); - + switch (status.status) { case 'connected': return `${displayName} connected`; @@ -171,18 +171,20 @@ export function getWalletStatusMessage( /** * Determines if reconnection is recommended - * + * * @param state - Current wallet connection state * @returns Whether reconnection should be suggested */ -export function shouldRecommendReconnect(state: WalletConnectionState): boolean { +export function shouldRecommendReconnect( + state: WalletConnectionState +): boolean { const status = getWalletConnectionStatus(state); return status.status === 'disconnected' || status.status === 'stale'; } /** * Creates a wallet connection state object - * + * * @param isConnected - Whether wallet is connected * @param address - Wallet address * @param walletName - Wallet name @@ -200,13 +202,13 @@ export function createWalletConnectionState( address, walletName, lastConnected: lastConnected || (isConnected ? Date.now() : undefined), - isStale: false + isStale: false, }; } /** * Updates a wallet connection state with new connection time - * + * * @param state - Existing state * @param address - New wallet address * @param walletName - Wallet name @@ -223,22 +225,39 @@ export function updateWalletConnection( address: address || state.address, walletName: walletName || state.walletName, lastConnected: Date.now(), - isStale: false + isStale: false, }; } /** * Clears wallet connection state - * + * * @param state - Existing state * @returns Cleared state */ -export function clearWalletConnection(state: WalletConnectionState): WalletConnectionState { +export function clearWalletConnection( + state: WalletConnectionState +): WalletConnectionState { return { ...state, isConnected: false, address: undefined, lastConnected: undefined, - isStale: false + isStale: false, }; } + +/** + * Determines if a target address belongs to the connected wallet + * + * @param address - The target address to check + * @param connectedAddress - The connected wallet address (null if not connected) + * @returns Whether the target address belongs to the connected wallet + */ +export function isOwnWallet( + address: string, + connectedAddress: string | null +): boolean { + if (connectedAddress === null) return false; + return address.toLowerCase() === connectedAddress.toLowerCase(); +}