diff --git a/src/hooks/useCreators.ts b/src/hooks/useCreators.ts index 1ea1cec2..32fd514d 100644 --- a/src/hooks/useCreators.ts +++ b/src/hooks/useCreators.ts @@ -1,6 +1,9 @@ -import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { useQuery } from '@tanstack/react-query'; import { queryKeys } from '@/lib/queryKeys'; -import type { GetCoursesParams } from '@/services/course.service'; +import { + courseService, + type GetCoursesParams, +} from '@/services/course.service'; export function useCreatorList(params?: GetCoursesParams) { return useQuery({ @@ -10,31 +13,10 @@ export function useCreatorList(params?: GetCoursesParams) { } export function useCreatorDetail(id: string) { - const queryClient = useQueryClient(); - return useQuery({ queryKey: queryKeys.creators.detail(id), - queryFn: async () => { - const key = queryKeys.creators.detail(id); - const cached = queryClient.getQueryData(key); - const isCacheMiss = cached === undefined; - - if (isCacheMiss && typeof process !== 'undefined' && process.env?.NODE_ENV !== 'test') { - const startMs = Date.now(); - const result = await Promise.resolve(null); - const duration_ms = Date.now() - startMs; - - console.debug('[creator-profile]', { - creator_id: id, - cache_status: 'miss', - duration_ms, - }); - - return result; - } - - return null; - }, + queryFn: () => courseService.getCourse(id), enabled: !!id, }); } + diff --git a/src/pages/CreatorDetailPage.tsx b/src/pages/CreatorDetailPage.tsx new file mode 100644 index 00000000..442779cf --- /dev/null +++ b/src/pages/CreatorDetailPage.tsx @@ -0,0 +1,74 @@ +import { useParams } from 'react-router'; +import { useCreatorDetail } from '@/hooks/useCreators'; +import CreatorBreadcrumb from '@/components/common/CreatorBreadcrumb'; +import CreatorProfileHeader from '@/components/common/CreatorProfileHeader'; +import CreatorProfileInfoGrid from '@/components/common/CreatorProfileInfoGrid'; +import { CreatorProfileHeaderSkeleton } from '@/components/common/CreatorSkeleton'; +import { bpsToPercent } from '@/utils/numberFormat.utils'; +import CreatorPageErrorBoundary from '@/components/common/CreatorPageErrorBoundary'; + +function CreatorDetailPageContent() { + const { id } = useParams<{ id: string }>(); + const { data: creator, isLoading, error } = useCreatorDetail(id || ''); + + if (isLoading) { + return ( +
+
+ +
+
+ ); + } + + if (error || !creator) { + throw new Error('Creator not found'); + } + + const feeItems = [ + { + label: 'Creator fee', + value: bpsToPercent(creator.creatorFeeBps), + helperText: 'Fee paid directly to the creator on each trade.', + }, + { + label: 'Protocol fee', + value: bpsToPercent(creator.protocolFeeBps), + helperText: 'Fee paid to the platform for protocol maintenance.', + }, + ]; + + return ( +
+
+ + +
+

+ Fee Structure +

+ +
+
+
+ ); +} + +export default function CreatorDetailPage() { + return ( + + + + ); +} diff --git a/src/pages/__tests__/CreatorDetailPage.integration.test.tsx b/src/pages/__tests__/CreatorDetailPage.integration.test.tsx new file mode 100644 index 00000000..f0ecad7c --- /dev/null +++ b/src/pages/__tests__/CreatorDetailPage.integration.test.tsx @@ -0,0 +1,110 @@ +import type { ComponentProps, ReactNode } from 'react'; +import { 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'; + +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 } }, + }); +} + +describe('CreatorDetailPage Integration', () => { + let queryClient: QueryClient; + + beforeEach(() => { + queryClient = makeFreshQueryClient(); + mockGetCourse.mockReset(); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + 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(); + }); +}); diff --git a/src/routes.tsx b/src/routes.tsx index af7577b8..4bb54361 100644 --- a/src/routes.tsx +++ b/src/routes.tsx @@ -1,11 +1,16 @@ import HomePage from './pages/HomePage'; import NotFoundPage from './pages/NotFoundPage'; +import CreatorDetailPage from './pages/CreatorDetailPage'; export const routes = [ { path: '/', element: , }, + { + path: '/creators/:id', + element: , + }, { path: '*', element: , diff --git a/src/services/course.service.ts b/src/services/course.service.ts index bf714667..ace2eb57 100644 --- a/src/services/course.service.ts +++ b/src/services/course.service.ts @@ -23,6 +23,8 @@ 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[]; } diff --git a/src/utils/__tests__/numberFormat.utils.test.ts b/src/utils/__tests__/numberFormat.utils.test.ts index b717784d..1f74df8e 100644 --- a/src/utils/__tests__/numberFormat.utils.test.ts +++ b/src/utils/__tests__/numberFormat.utils.test.ts @@ -5,6 +5,7 @@ import { formatFollowerCount, formatHolderCount, formatPercent, + bpsToPercent, } from '../numberFormat.utils'; // --------------------------------------------------------------------------- @@ -111,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' + ); }); }); @@ -242,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/numberFormat.utils.ts b/src/utils/numberFormat.utils.ts index 18b48bc2..cee835c7 100644 --- a/src/utils/numberFormat.utils.ts +++ b/src/utils/numberFormat.utils.ts @@ -113,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); +}