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);
+}