Skip to content
Open
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
6 changes: 3 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,9 @@ jobs:
uses: actions/checkout@v4

- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 10.6.5
run: |
corepack enable
corepack prepare pnpm@10.6.5 --activate

- name: Setup Node.js
uses: actions/setup-node@v4
Expand Down
150 changes: 150 additions & 0 deletions src/components/common/PriceHistoryChart.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
import {
CartesianGrid,
Line,
LineChart,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from 'recharts';
import type { ReactNode } from 'react';
import Skeleton from '@/components/ui/skeleton';
import { cn } from '@/lib/utils';
import type {
PriceHistoryInterval,
PriceHistoryPoint,
} from '@/services/course.service';

interface PriceHistoryChartProps {
data?: PriceHistoryPoint[];
interval: PriceHistoryInterval;
isLoading: boolean;
onIntervalChange: (interval: PriceHistoryInterval) => void;
}

const intervals: Array<{ value: PriceHistoryInterval; label: string }> = [
{ value: '1h', label: '1H' },
{ value: '24h', label: '24H' },
{ value: '7d', label: '7D' },
];

const formatTime = (value: ReactNode) =>
new Intl.DateTimeFormat(undefined, {
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
}).format(new Date(String(value)));

export function PriceHistoryChart({
data = [],
interval,
isLoading,
onIntervalChange,
}: PriceHistoryChartProps) {
return (
<section
className="rounded-4xl border border-white/10 bg-white/2 p-6 shadow-2xl backdrop-blur-md md:p-8"
aria-labelledby="price-history-heading"
data-testid="price-history-chart"
>
<div className="mb-6 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<h2
id="price-history-heading"
className="font-grotesque text-xl font-black tracking-tight text-white"
>
Price history
</h2>
<div
className="flex w-fit rounded-lg border border-white/10 bg-black/20 p-1"
role="group"
aria-label="Price history interval"
>
{intervals.map(option => (
<button
key={option.value}
type="button"
className={cn(
'rounded-md px-3 py-1.5 text-xs font-bold tracking-wider transition-colors',
interval === option.value
? 'bg-emerald-400 text-slate-950'
: 'text-white/55 hover:bg-white/10 hover:text-white'
)}
aria-pressed={interval === option.value}
data-testid={`price-history-interval-${option.value}`}
onClick={() => onIntervalChange(option.value)}
>
{option.label}
</button>
))}
</div>
</div>

{isLoading ? (
<div
className="h-65 w-full rounded-lg"
role="status"
aria-label="Loading price history"
data-testid="price-history-skeleton"
>
<Skeleton className="h-full w-full" />
</div>
) : data.length === 0 ? (
<div
className="flex h-65 items-center justify-center text-sm text-white/45"
data-testid="price-history-empty"
>
No price history yet
</div>
) : (
<div className="h-65 w-full" data-testid="price-history-series">
<ResponsiveContainer width="100%" height="100%">
<LineChart
data={data}
margin={{ top: 8, right: 8, left: 0, bottom: 8 }}
>
<CartesianGrid stroke="#ffffff12" vertical={false} />
<XAxis
dataKey="timestamp"
stroke="#ffffff55"
tickLine={false}
axisLine={false}
tickFormatter={formatTime}
minTickGap={32}
/>
<YAxis
dataKey="price"
stroke="#ffffff55"
tickLine={false}
axisLine={false}
width={52}
tickFormatter={value => `${value} XLM`}
/>
<Tooltip
contentStyle={{
backgroundColor: '#0b1728',
borderColor: '#ffffff22',
borderRadius: '0.5rem',
color: '#fff',
}}
labelFormatter={formatTime}
formatter={(value: unknown) => [
`${String(value)} XLM`,
'Price',
]}
/>
<Line
type="monotone"
dataKey="price"
stroke="#34d399"
strokeWidth={2}
dot={{ fill: '#34d399', r: 3, strokeWidth: 0 }}
activeDot={{ r: 5 }}
/>
</LineChart>
</ResponsiveContainer>
</div>
)}
</section>
);
}
52 changes: 52 additions & 0 deletions src/components/common/__tests__/PriceHistoryChart.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import { PriceHistoryChart } from '../PriceHistoryChart';

describe('PriceHistoryChart', () => {
it('shows a loading skeleton', () => {
render(
<PriceHistoryChart
interval="24h"
isLoading
onIntervalChange={vi.fn()}
/>
);

expect(screen.getByTestId('price-history-skeleton')).toBeInTheDocument();
expect(
screen.queryByTestId('price-history-empty')
).not.toBeInTheDocument();
});

it('shows the empty state when there is no history', () => {
render(
<PriceHistoryChart
data={[]}
interval="24h"
isLoading={false}
onIntervalChange={vi.fn()}
/>
);

expect(screen.getByText('No price history yet')).toBeInTheDocument();
});

it('renders the series and changes interval', () => {
const onIntervalChange = vi.fn();
render(
<PriceHistoryChart
data={[
{ timestamp: '2026-08-25T10:00:00Z', price: 1 },
{ timestamp: '2026-08-25T11:00:00Z', price: 2 },
]}
interval="24h"
isLoading={false}
onIntervalChange={onIntervalChange}
/>
);

expect(screen.getByTestId('price-history-series')).toBeInTheDocument();
fireEvent.click(screen.getByTestId('price-history-interval-7d'));
expect(onIntervalChange).toHaveBeenCalledWith('7d');
});
});
13 changes: 10 additions & 3 deletions src/hooks/useCreators.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
courseService,
type Course,
type GetCoursesParams,
type PriceHistoryInterval,
} from '@/services/course.service';
import showToast from '@/utils/toast.util';

Expand All @@ -22,6 +23,14 @@ export function useCreatorDetail(id: string) {
});
}

export function usePriceHistory(id: string, interval: PriceHistoryInterval) {
return useQuery({
queryKey: queryKeys.creators.priceHistory(id, interval),
queryFn: () => courseService.getPriceHistory(id, interval),
enabled: !!id,
});
}

export function useSetCoCreator(courseId: string) {
const queryClient = useQueryClient();

Expand All @@ -46,6 +55,4 @@ export function useSetCoCreator(courseId: string) {
showToast.error(message);
},
});
}


}
2 changes: 2 additions & 0 deletions src/lib/queryKeys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ export const queryKeys = {
infiniteList: (params?: Omit<GetCoursesParams, 'page'>) =>
['creators', 'infiniteList', params ?? null] as const,
detail: (id: string) => ['creators', 'detail', id] as const,
priceHistory: (creatorId: string, interval: string) =>
['creators', creatorId, 'priceHistory', interval] as const,
holders: (creatorId: string) =>
['creators', creatorId, 'holders'] as const,
activity: (creatorId: string) =>
Expand Down
22 changes: 18 additions & 4 deletions src/pages/CreatorDetailPage.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { Link, useLocation, useNavigate, useParams } from 'react-router';
import { useEffect, useState } from 'react';
import { useCreatorDetail } from '@/hooks/useCreators';
import { useCreatorDetail, usePriceHistory } from '@/hooks/useCreators';
import { useCreatorProfileStaleIndicator } from '@/hooks/useCreatorProfileStaleIndicator';
import CreatorBreadcrumb from '@/components/common/CreatorBreadcrumb';
import CreatorProfileHeader from '@/components/common/CreatorProfileHeader';
Expand Down Expand Up @@ -28,6 +28,8 @@ import { useProfileStore } from '@/hooks/useProfileStore';
import { useWalletHoldings } from '@/hooks/useWallet';
import CoCreatorSection from '@/components/creator/CoCreatorSection';
import ShareTwitterButton from '@/components/common/ShareTwitterButton';
import { PriceHistoryChart } from '@/components/common/PriceHistoryChart';
import type { PriceHistoryInterval } from '@/services/course.service';
import { useDocumentTitle } from '@/hooks/useDocumentTitle';

function CreatorDetailPageContent() {
Expand All @@ -42,6 +44,11 @@ function CreatorDetailPageContent() {
isFetching,
refetch,
} = useCreatorDetail(id || '');

const [interval, setInterval] = useState<PriceHistoryInterval>('24h');
const { data: priceHistory, isLoading: isPriceHistoryLoading } =
usePriceHistory(id || '', interval);

useNavigationTiming('creator_profile');
useDocumentTitle(creator ? `${creator.title} — AccessLayer` : null);

Expand Down Expand Up @@ -158,7 +165,6 @@ function CreatorDetailPageContent() {
recentFeeInflow: creator.recentFeeInflow,
}
: {
// Demo values until the key detail API returns staking pool stats.
stakingPoolBalance: 4820,
totalStaked: creator.creatorShareSupply
? Math.floor(creator.creatorShareSupply / 4)
Expand Down Expand Up @@ -193,6 +199,14 @@ function CreatorDetailPageContent() {
}}
/>

{/* Historical Price Chart */}
<PriceHistoryChart
data={priceHistory}
interval={interval}
isLoading={isPriceHistoryLoading}
onIntervalChange={setInterval}
/>

{/* 4 Stat Cards */}
<div data-testid="creator-stat-cards">
<CreatorProfileStatRow items={statItems} />
Expand All @@ -219,7 +233,7 @@ function CreatorDetailPageContent() {
{/* Staking Rewards */}
<StakingRewardsSection {...stakingStats} isLoading={isLoading} />

{/* Price Chart */}
{/* Price Curve Chart */}
<div
className="rounded-[2rem] border border-white/10 bg-white/[0.02] p-6 shadow-2xl backdrop-blur-md md:p-8"
data-testid="creator-chart-container"
Expand Down Expand Up @@ -310,4 +324,4 @@ export default function CreatorDetailPage() {
<CreatorDetailPageContent />
</KeyDetailPageErrorBoundary>
);
}
}
5 changes: 5 additions & 0 deletions src/pages/__tests__/CreatorDetailPage.integration.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { queryKeys } from '@/lib/queryKeys';
vi.mock('@/services/course.service', () => ({
courseService: {
getCourse: vi.fn(),
getPriceHistory: vi.fn(),
getHoldersPage: vi.fn(),
},
}));
Expand Down Expand Up @@ -44,6 +45,7 @@ vi.mock('framer-motion', async () => {
});

const mockGetCourse = vi.mocked(courseService.getCourse);
const mockGetPriceHistory = vi.mocked(courseService.getPriceHistory);
const mockGetHoldersPage = vi.mocked(courseService.getHoldersPage);

function makeFreshQueryClient() {
Expand All @@ -70,6 +72,7 @@ describe('CreatorDetailPage Integration', () => {
beforeEach(() => {
queryClient = makeFreshQueryClient();
mockGetCourse.mockReset();
mockGetPriceHistory.mockResolvedValue([]);
mockGetHoldersPage.mockReset();
mockGetHoldersPage.mockResolvedValue({
holders: [],
Expand Down Expand Up @@ -278,6 +281,7 @@ describe('CreatorDetailPage Integration', () => {
</QueryClientProvider>
);

expect(await screen.findByText('100.00 XLM')).toBeInTheDocument();
expect(await screen.findAllByText('100.00 XLM')).not.toHaveLength(0);
expect(
screen.queryByLabelText(/loading creator profile/i)
Expand All @@ -289,6 +293,7 @@ describe('CreatorDetailPage Integration', () => {
});
});

expect(screen.getByText('100.00 XLM')).toBeInTheDocument();
expect(screen.getAllByText('100.00 XLM')).not.toHaveLength(0);
expect(
screen.queryByLabelText(/loading creator profile/i)
Expand Down
Loading
Loading