From 4f7b1622d7642b42c18d2a5b939db511906fd631 Mon Sep 17 00:00:00 2001 From: uche102 Date: Thu, 24 Sep 2026 20:42:52 +0100 Subject: [PATCH] feat: add TWAP overlay to creator price chart --- src/components/common/BondingCurveChart.tsx | 100 ++++++++-- .../__tests__/BondingCurveChart.test.tsx | 54 +++++- src/hooks/useKeyTwap.ts | 9 +- src/lib/queryKeys.ts | 4 +- src/pages/CreatorDetailPage.tsx | 172 ++++++++++++++---- src/services/course.service.ts | 23 ++- 6 files changed, 291 insertions(+), 71 deletions(-) diff --git a/src/components/common/BondingCurveChart.tsx b/src/components/common/BondingCurveChart.tsx index e0d6291c..53b2f656 100644 --- a/src/components/common/BondingCurveChart.tsx +++ b/src/components/common/BondingCurveChart.tsx @@ -19,6 +19,8 @@ export interface BondingCurveDataPoint { export interface BondingCurveChartProps { data?: BondingCurveDataPoint[]; currentSupply?: number; + twapPriceXLM?: number | null; + twapLabel?: string; className?: string; width?: number | `${number}%`; height?: number | `${number}%`; @@ -27,6 +29,8 @@ export interface BondingCurveChartProps { export function BondingCurveChart({ data = [], currentSupply, + twapPriceXLM, + twapLabel = 'TWAP', className, width = '100%', height = 300, @@ -45,25 +49,33 @@ export function BondingCurveChart({ ); } - const maxSupplyInData = Math.max(...data.map((d) => d.supply)); + const isTwapAvailable = + twapPriceXLM != null && Number.isFinite(twapPriceXLM) && twapPriceXLM > 0; + const chartData = isTwapAvailable + ? data.map(point => ({ ...point, twapPriceXLM })) + : data; + const maxSupplyInData = Math.max(...data.map(d => d.supply)); const xAxisMax = currentSupply ?? maxSupplyInData; const currentPoint = data.find( - (d) => d.isCurrent || (currentSupply !== undefined && d.supply === currentSupply) + d => + d.isCurrent || + (currentSupply !== undefined && d.supply === currentSupply) ); -interface CustomDotProps { - cx?: number; - cy?: number; - payload?: BondingCurveDataPoint; -} + interface CustomDotProps { + cx?: number; + cy?: number; + payload?: BondingCurveDataPoint & { twapPriceXLM?: number }; + } const CustomDot = (props: CustomDotProps) => { const { cx, cy, payload } = props; if (!cx || !cy || !payload) return null; const isHighlighted = - payload.isCurrent || (currentSupply !== undefined && payload.supply === currentSupply); + payload.isCurrent || + (currentSupply !== undefined && payload.supply === currentSupply); return ( ); }; + const renderTwapTooltip = ({ + active, + payload, + label, + }: { + active?: boolean; + payload?: Array<{ + dataKey?: string; + value?: number | string; + color?: string; + }>; + label?: string | number; + }) => { + if (!active || !payload || payload.length === 0) return null; + + const spotEntry = payload.find(entry => entry.dataKey === 'priceXLM'); + const twapEntry = payload.find(entry => entry.dataKey === 'twapPriceXLM'); + const spotValue = Number(spotEntry?.value ?? 0); + const twapValue = Number(twapEntry?.value ?? 0); + const deviation = + spotValue > 0 ? ((twapValue - spotValue) / spotValue) * 100 : 0; + + return ( +
+
+ {twapLabel} +
+
+
Spot: {spotValue.toFixed(4)} XLM
+
TWAP: {twapValue.toFixed(4)} XLM
+
= 0 ? 'text-amber-300' : 'text-emerald-300' + } + > + Deviation: {deviation >= 0 ? '+' : ''} + {deviation.toFixed(2)}% +
+ {label !== undefined && ( +
Supply: {label}
+ )} +
+
+ ); + }; + return (
@@ -114,6 +179,7 @@ interface CustomDotProps { data-testid="y-axis" /> } activeDot={{ r: 8, className: 'highlight-active-dot' }} /> + {isTwapAvailable && ( + + )} {currentPoint && ( { +vi.mock('recharts', async importOriginal => { const original = await importOriginal(); return { ...original, @@ -19,7 +22,11 @@ vi.mock('recharts', async (importOriginal) => { width?: string | number; height?: string | number; }) => ( -
+
{children}
) @@ -34,7 +41,10 @@ vi.mock('recharts', async (importOriginal) => { data?: unknown; 'data-testid'?: string; }) => ( -
+
{children}
) @@ -57,9 +67,13 @@ vi.mock('recharts', async (importOriginal) => { ) ), YAxis: vi.fn( - ({ dataKey, 'data-testid': testId }: { dataKey?: string; 'data-testid'?: string }) => ( -
- ) + ({ + dataKey, + 'data-testid': testId, + }: { + dataKey?: string; + 'data-testid'?: string; + }) =>
), Tooltip: vi.fn(() =>
), CartesianGrid: vi.fn(() =>
), @@ -138,7 +152,9 @@ describe('BondingCurveChart', () => { ]; const currentSupply = 25; - render(); + render( + + ); const chartElement = screen.getByTestId('bonding-curve-chart'); expect(chartElement).toHaveAttribute('data-xaxis-max', '25'); @@ -167,6 +183,28 @@ describe('BondingCurveChart', () => { expect(referenceDot).toHaveAttribute('data-y', '1.1'); }); + it('renders a TWAP overlay line when a twap price is supplied', () => { + const sampleData: BondingCurveDataPoint[] = [ + { supply: 10, priceXLM: 1.5 }, + { supply: 20, priceXLM: 2.0, isCurrent: true }, + ]; + + render( + + ); + + const lineCalls = vi + .mocked(recharts.Line) + .mock.calls.map(([props]) => props); + expect(lineCalls.some(props => props?.dataKey === 'twapPriceXLM')).toBe( + true + ); + }); + it('mocks the chart library and asserts it is called with the correct data shape', () => { const sampleData: BondingCurveDataPoint[] = [ { supply: 10, priceXLM: 1.5 }, diff --git a/src/hooks/useKeyTwap.ts b/src/hooks/useKeyTwap.ts index 6dd8f504..c6f2b8d7 100644 --- a/src/hooks/useKeyTwap.ts +++ b/src/hooks/useKeyTwap.ts @@ -2,12 +2,15 @@ import { useQuery } from '@tanstack/react-query'; import { queryKeys } from '@/lib/queryKeys'; import { courseService } from '@/services/course.service'; -export function useKeyTwap(keyId: string) { +export type KeyTwapWindow = '1h' | '24h'; + +export function useKeyTwap(keyId: string, window: KeyTwapWindow = '24h') { return useQuery({ - queryKey: queryKeys.creators.twap(keyId), - queryFn: () => courseService.getKeyTwap(keyId), + queryKey: queryKeys.creators.twap(keyId, window), + queryFn: () => courseService.getKeyTwap(keyId, window), enabled: !!keyId, staleTime: 60_000, + refetchInterval: 60_000, retry: false, }); } diff --git a/src/lib/queryKeys.ts b/src/lib/queryKeys.ts index d511adb4..dcb5c10a 100644 --- a/src/lib/queryKeys.ts +++ b/src/lib/queryKeys.ts @@ -25,8 +25,8 @@ export const queryKeys = { ['creators', creatorId, 'holders'] as const, activity: (creatorId: string) => ['creators', creatorId, 'activity'] as const, - twap: (creatorId: string) => - ['creators', creatorId, 'twap', '24h'] as const, + twap: (creatorId: string, window: '1h' | '24h' = '24h') => + ['creators', creatorId, 'twap', window] as const, curveConfig: (creatorId: string) => ['creators', creatorId, 'curve-config'] as const, }, diff --git a/src/pages/CreatorDetailPage.tsx b/src/pages/CreatorDetailPage.tsx index 1c2e4e53..b93dbd2b 100644 --- a/src/pages/CreatorDetailPage.tsx +++ b/src/pages/CreatorDetailPage.tsx @@ -35,6 +35,7 @@ import { useDocumentTitle } from '@/hooks/useDocumentTitle'; import { useKeyTwap } from '@/hooks/useKeyTwap'; import Skeleton from '@/components/ui/skeleton'; import { Tooltip } from '@/components/ui/tooltip'; +import { cn } from '@/lib/utils'; function CreatorDetailPageContent() { usePurchaseConfetti(); @@ -77,6 +78,21 @@ function CreatorDetailPageContent() { const { holders, hasNextPage, isFetchingNextPage, fetchNextPage } = useKeyHolders(id || ''); + const [selectedTwapWindow, setSelectedTwapWindow] = useState<'1h' | '24h'>( + '24h' + ); + const { data: twap1h, isLoading: isTwap1hLoading } = useKeyTwap( + id || '', + '1h' + ); + const { data: twap24h, isLoading: isTwap24hLoading } = useKeyTwap( + id || '', + '24h' + ); + const activeTwap = selectedTwapWindow === '1h' ? twap1h : twap24h; + const isTwapLoading = + selectedTwapWindow === '1h' ? isTwap1hLoading : isTwap24hLoading; + // User holdings for Share to X button const profile = useProfileStore(state => state.profile); const userAddress = profile?.id; @@ -88,7 +104,6 @@ function CreatorDetailPageContent() { // return a per-user one. Only shown for authenticated users. const nextBuyAllowedAt = userPosition?.nextBuyAllowedAt ?? creator?.nextBuyAllowedAt ?? null; - const { data: twap, isLoading: isTwapLoading } = useKeyTwap(id || ''); // Track stale data indicator const { shouldShowBadge, handleRefetch } = useCreatorProfileStaleIndicator( @@ -173,8 +188,16 @@ function CreatorDetailPageContent() { priceXLM: priceStroops / 10_000_000, })); const spotPrice = resolveCreatorKeyPriceStroops(creator); - const twapPrice = twap?.priceStroops ?? null; - const twapDelta = twapPrice != null && spotPrice != null ? twapPrice - spotPrice : null; + const twapPrice = activeTwap?.priceStroops ?? null; + const twapPriceXLM = twapPrice != null ? twapPrice / 10_000_000 : null; + const twapDelta = + twapPrice != null && spotPrice != null ? twapPrice - spotPrice : null; + const twapDeviationPercent = + twapPriceXLM != null && spotPrice != null && spotPrice !== 0 + ? ((twapPriceXLM - spotPrice / 10_000_000) / + (spotPrice / 10_000_000)) * + 100 + : null; const hasRealStakingData = creator.stakingPoolBalance != null || @@ -203,7 +226,6 @@ function CreatorDetailPageContent() { parentHref="/" currentLabel={`${creator.title} Profile`} /> -
{ - if (window.history.length > 1 && location.key !== 'default') { + if ( + window.history.length > 1 && + location.key !== 'default' + ) { navigate(-1); return; } @@ -230,17 +255,14 @@ function CreatorDetailPageContent() { className="mt-3 shrink-0" />
- {/* 4 Stat Cards */}
- {/* Buy Cooldown Countdown (only meaningful for authenticated users) */} {userAddress && ( )} - {/* Share to X Button (only visible for authenticated holders) */}
- - {isTwapLoading ? ( -
-
-
- ) : twapPrice != null ? ( -
-
-
-
- TWAP (24h) - - - +
+
+
+
+ + + + + TWAP ({selectedTwapWindow === '1h' ? '1h' : '24h'}) + +
+
+
+ {(['1h', '24h'] as const).map(window => ( + + ))}
-
{formatDisplayKeyPrice(twapPrice)}
- {twapDelta != null && {twapDelta < 0 ? '▼' : '▲'} {formatDisplayKeyPrice(Math.abs(twapDelta))} vs spot}
+ {isTwapLoading ? ( +
+ + +
+ ) : twapPrice != null ? ( +
+
+ {formatDisplayKeyPrice(twapPrice)} +
+ {twapDelta != null && ( + + {twapDelta < 0 ? '▼' : '▲'}{' '} + {formatDisplayKeyPrice(Math.abs(twapDelta))} vs + spot + + )} + {twapDeviationPercent != null && ( +
+ {twapDeviationPercent >= 0 ? '+' : ''} + {twapDeviationPercent.toFixed(2)}% from spot +
+ )} +
+ ) : ( +
+ TWAP unavailable +
+ )}
- ) : null} - +
{/* Staking Rewards */} - {/* Price Chart */}
- {/* Buy Simulation Tool */} - {/* Holder Concentration */}
- {/* Fee Structure */}
@@ -328,15 +423,14 @@ function CreatorDetailPageContent() {
- - {/* Co-Creator Section */} - + {/* Co-Creator Section */}{' '} + {/* Activity Feed */}

diff --git a/src/services/course.service.ts b/src/services/course.service.ts index 7c6aa833..258651e4 100644 --- a/src/services/course.service.ts +++ b/src/services/course.service.ts @@ -109,7 +109,10 @@ export interface GraduatedCurveConfig { } export type CourseSortOption = - 'volume_desc' | 'price_asc' | 'price_desc' | 'newest'; + | 'volume_desc' + | 'price_asc' + | 'price_desc' + | 'newest'; export interface GetCoursesParams { page?: number; @@ -158,10 +161,12 @@ export interface KeyHoldersPage { nextCursor: string | null; } +export type KeyTwapWindow = '1h' | '24h'; + export interface KeyTwap { - /** 24-hour time-weighted average price in stroops. */ + /** Time-weighted average price in stroops for the requested window. */ priceStroops: number | null; - window?: string; + window?: KeyTwapWindow; } class CourseService extends BaseApiService { @@ -268,7 +273,10 @@ class CourseService extends BaseApiService { } // Get the time-weighted average price - GET /keys/:keyId/twap - async getKeyTwap(keyId: string, window = '24h'): Promise { + async getKeyTwap( + keyId: string, + window: KeyTwapWindow = '24h' + ): Promise { try { const response = await this.api.get>( `/keys/${keyId}/twap`, @@ -384,10 +392,9 @@ class CourseService extends BaseApiService { quantity: number ): Promise> { try { - const response = await this.api.get>>( - `/keys/${keyId}/simulate`, - { params: { quantity } } - ); + const response = await this.api.get< + APIResponse> + >(`/keys/${keyId}/simulate`, { params: { quantity } }); return response.data.data; } catch (error) { throw this.handleError(error);