Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 7 additions & 25 deletions src/hooks/useCreators.ts
Original file line number Diff line number Diff line change
@@ -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({
Expand All @@ -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,
});
}

74 changes: 74 additions & 0 deletions src/pages/CreatorDetailPage.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<main className="min-h-screen bg-[#06111f] px-6 py-16 text-white md:px-12">
<div className="mx-auto max-w-7xl space-y-6">
<CreatorProfileHeaderSkeleton />
</div>
</main>
);
}

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 (
<main className="min-h-screen bg-[#06111f] px-6 py-16 text-white md:px-12">
<div className="mx-auto max-w-7xl space-y-8">
<CreatorBreadcrumb
parentLabel="Marketplace"
parentHref="/"
currentLabel={`${creator.title} Profile`}
/>
<CreatorProfileHeader
name={creator.title}
handle={creator.socialHandle || creator.instructorId}
creatorId={creator.id}
isVerified={creator.isVerified}
avatarUrl={creator.thumbnail}
bio={creator.description}
/>
<div className="mt-8 rounded-[2rem] border border-white/10 bg-white/[0.02] p-6 shadow-2xl backdrop-blur-md md:p-8">
<h2 className="font-grotesque text-xl font-black tracking-tight text-white mb-6">
Fee Structure
</h2>
<CreatorProfileInfoGrid items={feeItems} />
</div>
</div>
</main>
);
}

export default function CreatorDetailPage() {
return (
<CreatorPageErrorBoundary>
<CreatorDetailPageContent />
</CreatorPageErrorBoundary>
);
}
110 changes: 110 additions & 0 deletions src/pages/__tests__/CreatorDetailPage.integration.test.tsx
Original file line number Diff line number Diff line change
@@ -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(
<QueryClientProvider client={queryClient}>
<MemoryRouter initialEntries={['/creators/creator-123']}>
<Routes>
<Route path="/creators/:id" element={<CreatorDetailPage />} />
</Routes>
</MemoryRouter>
</QueryClientProvider>
);

// 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();
});
});
5 changes: 5 additions & 0 deletions src/routes.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,16 @@
import HomePage from './pages/HomePage';
import NotFoundPage from './pages/NotFoundPage';
import CreatorDetailPage from './pages/CreatorDetailPage';

export const routes = [
{
path: '/',
element: <HomePage />,
},
{
path: '/creators/:id',
element: <CreatorDetailPage />,
},
{
path: '*',
element: <NotFoundPage />,
Expand Down
2 changes: 2 additions & 0 deletions src/services/course.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
}
Expand Down
39 changes: 36 additions & 3 deletions src/utils/__tests__/numberFormat.utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
formatFollowerCount,
formatHolderCount,
formatPercent,
bpsToPercent,
} from '../numberFormat.utils';

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -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'
);
});
});

Expand Down Expand Up @@ -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');
});
});
12 changes: 12 additions & 0 deletions src/utils/numberFormat.utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Loading