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
13 changes: 13 additions & 0 deletions src/hooks/useKeyTwap.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { useQuery } from '@tanstack/react-query';
import { queryKeys } from '@/lib/queryKeys';
import { courseService } from '@/services/course.service';

export function useKeyTwap(keyId: string) {
return useQuery({
queryKey: queryKeys.creators.twap(keyId),
queryFn: () => courseService.getKeyTwap(keyId),
enabled: !!keyId,
staleTime: 60_000,
retry: false,
});
}
2 changes: 2 additions & 0 deletions src/lib/queryKeys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +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,
},
wallet: {
holdings: (address: string) => ['wallet', address, 'holdings'] as const,
Expand Down
28 changes: 28 additions & 0 deletions src/pages/CreatorDetailPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@ import { resolveCreatorKeyPriceStroops, formatDisplayKeyPrice } from '@/utils/ke
import KeyDetailPageErrorBoundary from '@/components/common/KeyDetailPageErrorBoundary';
import { ApiError } from '@/services/api.service';
import { useNavigationTiming } from '@/hooks/useNavigationTiming';
import { useKeyTwap } from '@/hooks/useKeyTwap';
import Skeleton from '@/components/ui/skeleton';
import { Tooltip } from '@/components/ui/tooltip';
import { useKeyHolders } from '@/hooks/useKeyHolders';
import { useProfileStore } from '@/hooks/useProfileStore';
import { useWalletHoldings } from '@/hooks/useWallet';
Expand All @@ -39,6 +42,7 @@ function CreatorDetailPageContent() {
isFetchingNextPage,
fetchNextPage,
} = useKeyHolders(id || '');
const { data: twap, isLoading: isTwapLoading } = useKeyTwap(id || '');

// User holdings for Share to X button
const profile = useProfileStore(state => state.profile);
Expand Down Expand Up @@ -120,6 +124,9 @@ function CreatorDetailPageContent() {
supply: (index + 1) * 20,
priceXLM: priceStroops / 10_000_000,
}));
const spotPrice = resolveCreatorKeyPriceStroops(creator);
const twapPrice = twap?.priceStroops ?? null;
const twapDelta = twapPrice != null && spotPrice != null ? twapPrice - spotPrice : null;

const defaultHolders = [
{ id: 'h1', displayName: 'Early Adopter', keyCount: 25, stakedQuantity: 18 },
Expand Down Expand Up @@ -172,6 +179,27 @@ function CreatorDetailPageContent() {
<CreatorProfileStatRow items={statItems} />
</div>

{isTwapLoading ? (
<div className="rounded-2xl border border-white/10 bg-white/[0.03] px-5 py-4" data-testid="twap-price">
<div aria-label="Loading 24 hour TWAP" role="status"><Skeleton className="h-3 w-24" /><Skeleton className="mt-2 h-6 w-32" /></div>
</div>
) : twapPrice != null ? (
<div className="rounded-2xl border border-white/10 bg-white/[0.03] px-5 py-4" data-testid="twap-price">
<div className="flex items-center justify-between gap-4">
<div>
<div className="flex items-center gap-2 text-xs font-semibold uppercase tracking-wider text-white/55">
<span className={twapDelta != null ? (twapDelta < 0 ? 'text-emerald-400' : 'text-rose-400') : ''}>TWAP (24h)</span>
<Tooltip content="Time-weighted average price over the past 24 hours. Less sensitive to short-term manipulation.">
<button type="button" aria-label="What is 24 hour TWAP?" className="text-white/50">ⓘ</button>
</Tooltip>
</div>
<div className="mt-1 text-xl font-bold text-white">{formatDisplayKeyPrice(twapPrice)}</div>
</div>
{twapDelta != null && <span className={twapDelta < 0 ? 'text-sm font-semibold text-emerald-400' : 'text-sm font-semibold text-rose-400'}>{twapDelta < 0 ? '▼' : '▲'} {formatDisplayKeyPrice(Math.abs(twapDelta))} vs spot</span>}
</div>
</div>
) : null}

{/* Share to X Button (only visible for authenticated holders) */}
<div className="flex justify-end">
<ShareTwitterButton
Expand Down
24 changes: 24 additions & 0 deletions src/services/course.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,11 @@ export interface Course {
/** ISO timestamp for the next scheduled drop, when applicable. */
nextDropAt?: string;
creatorShareSupply?: number;
/** Optional co-creator payout metadata returned by creator-detail APIs. */
coCreatorAddress?: string;
coCreatorSplitBps?: number;
totalPaidToCoCreator?: number;
totalPaidToCreator?: number;
instructorId: string;
thumbnail?: string;
category: string;
Expand Down Expand Up @@ -106,6 +111,12 @@ export interface KeyHoldersPage {
nextCursor: string | null;
}

export interface KeyTwap {
/** 24-hour time-weighted average price in stroops. */
priceStroops: number | null;
window?: string;
}

class CourseService extends BaseApiService {
private readonly PROFILE_CACHE_TTL = 30000; // 30 seconds

Expand Down Expand Up @@ -209,6 +220,19 @@ class CourseService extends BaseApiService {
}
}

// Get the time-weighted average price - GET /keys/:keyId/twap
async getKeyTwap(keyId: string, window = '24h'): Promise<KeyTwap> {
try {
const response = await this.api.get<APIResponse<KeyTwap>>(
`/keys/${keyId}/twap`,
{ params: { window } }
);
return response.data.data;
} catch (error) {
throw this.handleError(error);
}
}

// Get enrolled courses - GET /courses/enrolled
async getEnrolledCourses(): Promise<Course[]> {
try {
Expand Down
Loading