diff --git a/src/components/common/PerformanceBondPanel.tsx b/src/components/common/PerformanceBondPanel.tsx new file mode 100644 index 00000000..599ee6d3 --- /dev/null +++ b/src/components/common/PerformanceBondPanel.tsx @@ -0,0 +1,241 @@ +import React from 'react'; +import { ShieldCheck, Lock, AlertTriangle, CheckCircle2 } from 'lucide-react'; +import AccessibleInfoTrigger from '@/components/common/AccessibleInfoTrigger'; +import Skeleton from '@/components/ui/skeleton'; +import type { PerformanceBond } from '@/services/course.service'; +import { formatNumber } from '@/utils/numberFormat.utils'; +import { formatDisplayKeyPrice } from '@/utils/keyPriceDisplay.utils'; +import { cn } from '@/lib/utils'; + +export interface PerformanceBondPanelProps { + /** Performance bond data for the creator key (#975). */ + bond?: PerformanceBond | null; + /** Loading state flag while bond data is being fetched. */ + isLoading?: boolean; + /** Error state flag. */ + isError?: boolean; + className?: string; +} + +const TOOLTIP_EXPLANATION = + 'A performance bond locks creator capital on-chain until maturity milestones are met, guaranteeing creator commitment and protecting investors against project abandonment.'; + +function formatBondAmount( + amountStroops?: number | null, + amountXlm?: number | null +): string { + if (amountStroops != null && Number.isFinite(amountStroops)) { + return formatDisplayKeyPrice(amountStroops); + } + if (amountXlm != null && Number.isFinite(amountXlm)) { + return `${formatNumber(amountXlm)} XLM`; + } + return '—'; +} + +function formatReleaseDate(releasedAt?: string | null): string { + if (!releasedAt) return '—'; + try { + const date = new Date(releasedAt); + if (isNaN(date.getTime())) return releasedAt; + return date.toLocaleDateString('en-US', { + year: 'numeric', + month: 'short', + day: 'numeric', + }); + } catch { + return releasedAt; + } +} + +/** + * Performance bond status panel for the creator key detail page (#975). + * + * Displays the bonded amount, current state (staked, released, forfeited), + * and target maturity milestone required for release. Hidden when no bond exists. + */ +export const PerformanceBondPanel: React.FC = ({ + bond, + isLoading = false, + isError = false, + className, +}) => { + // Panel not rendered when no bond exists and not loading (#975) + if (!isLoading && !bond) { + return null; + } + + if (isLoading && !bond) { + return ( +
+
+ + +
+
+ + +
+ + Loading performance bond status + +
+ ); + } + + if (isError && !bond) { + return null; + } + + if (!bond) return null; + + const stateLower = (bond.state || '').toLowerCase(); + const isStaked = stateLower === 'staked'; + const isReleased = stateLower === 'released'; + const isForfeited = stateLower === 'forfeited'; + + const formattedAmount = formatBondAmount(bond.amountStroops, bond.amountXlm); + const milestoneText = bond.milestone || bond.targetMilestone || '—'; + const forfeitureReasonText = bond.forfeitureReason || bond.reason || null; + const releaseDateText = formatReleaseDate(bond.releasedAt); + + return ( +
+ {/* Header with Title, Tooltip, and State Badge */} +
+
+
+ + {/* State Badge with distinct visual treatment */} +
+ {isStaked && ( + <> +
+
+ + {/* Info Grid: Bonded Amount & Maturity Milestone */} +
+
+
+ Bonded Amount +
+
+ {formattedAmount} +
+
+ +
+
+ Maturity Milestone +
+
+ {milestoneText} +
+
+
+ + {/* State-specific details */} + {isReleased && ( +
+ Bond Released + + Released on {releaseDateText} + +
+ )} + + {isForfeited && ( +
+
+
+

+ {forfeitureReasonText || + 'The creator failed to reach the required maturity milestone.'} +

+
+ )} +
+ ); +}; + +export default PerformanceBondPanel; diff --git a/src/components/common/__tests__/PerformanceBondPanel.test.tsx b/src/components/common/__tests__/PerformanceBondPanel.test.tsx new file mode 100644 index 00000000..e9074e93 --- /dev/null +++ b/src/components/common/__tests__/PerformanceBondPanel.test.tsx @@ -0,0 +1,107 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, expect, it } from 'vitest'; +import PerformanceBondPanel from '../PerformanceBondPanel'; +import type { PerformanceBond } from '@/services/course.service'; + +describe('PerformanceBondPanel', () => { + const mockStakedBond: PerformanceBond = { + keyId: 'creator-1', + amountStroops: 10_000_000_000, // 1000 XLM + state: 'staked', + milestone: '1,000 Keys Sold', + }; + + const mockReleasedBond: PerformanceBond = { + keyId: 'creator-2', + amountXlm: 500, + state: 'released', + milestone: '500 Holders', + releasedAt: '2026-08-15T12:00:00Z', + }; + + const mockForfeitedBond: PerformanceBond = { + keyId: 'creator-3', + amountStroops: 2_000_000_000, // 200 XLM + state: 'forfeited', + milestone: 'Active for 6 months', + forfeitureReason: 'Creator abandoned project before reaching milestone.', + }; + + it('renders nothing when no bond exists', () => { + const { container } = render(); + expect(container.firstChild).toBeNull(); + expect( + screen.queryByTestId('performance-bond-panel') + ).not.toBeInTheDocument(); + }); + + it('renders nothing when undefined bond and not loading', () => { + const { container } = render(); + expect(container.firstChild).toBeNull(); + }); + + it('renders loading skeleton when isLoading is true and no bond data', () => { + render(); + expect( + screen.getByTestId('performance-bond-panel-loading') + ).toBeInTheDocument(); + }); + + it('renders bonded amount, state, and milestone correctly for staked bond', () => { + render(); + + expect(screen.getByTestId('performance-bond-panel')).toBeInTheDocument(); + expect(screen.getByTestId('performance-bond-amount')).toHaveTextContent( + '1,000 XLM' + ); + expect(screen.getByTestId('performance-bond-state')).toHaveTextContent( + 'Staked' + ); + expect(screen.getByTestId('performance-bond-milestone')).toHaveTextContent( + '1,000 Keys Sold' + ); + }); + + it('renders released state with release timestamp clearly', () => { + render(); + + expect(screen.getByTestId('performance-bond-state')).toHaveTextContent( + 'Released' + ); + const releaseEl = screen.getByTestId('performance-bond-released-at'); + expect(releaseEl).toBeInTheDocument(); + expect(releaseEl).toHaveTextContent(/Released on/i); + expect(releaseEl).toHaveTextContent(/Aug 15, 2026/i); + }); + + it('renders forfeited state with forfeiture reason clearly', () => { + render(); + + expect(screen.getByTestId('performance-bond-state')).toHaveTextContent( + 'Forfeited' + ); + const reasonEl = screen.getByTestId('performance-bond-reason'); + expect(reasonEl).toBeInTheDocument(); + expect(reasonEl).toHaveTextContent( + 'Creator abandoned project before reaching milestone.' + ); + }); + + it('renders tooltip explaining the investor protection role', async () => { + const user = userEvent.setup(); + render(); + + const triggerBtn = screen.getByRole('button', { + name: /explanation for performance bond/i, + }); + expect(triggerBtn).toBeInTheDocument(); + + await user.hover(triggerBtn); + expect( + await screen.findByText( + /locks creator capital on-chain until maturity milestones are met/i + ) + ).toBeInTheDocument(); + }); +}); diff --git a/src/hooks/__tests__/usePerformanceBond.test.ts b/src/hooks/__tests__/usePerformanceBond.test.ts new file mode 100644 index 00000000..3d2a0215 --- /dev/null +++ b/src/hooks/__tests__/usePerformanceBond.test.ts @@ -0,0 +1,74 @@ +import { renderHook, waitFor } from '@testing-library/react'; +import { describe, expect, it, vi, beforeEach } from 'vitest'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import React, { type ReactNode } from 'react'; +import { usePerformanceBond } from '../usePerformanceBond'; +import { courseService, type PerformanceBond } from '@/services/course.service'; + +vi.mock('@/services/course.service', async () => { + const actual = await vi.importActual( + '@/services/course.service' + ); + return { + ...actual, + courseService: { + ...actual.courseService, + getPerformanceBond: vi.fn(), + }, + }; +}); + +const mockGetPerformanceBond = vi.mocked(courseService.getPerformanceBond); + +function createWrapper() { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + return ({ children }: { children: ReactNode }) => + React.createElement(QueryClientProvider, { client: queryClient }, children); +} + +describe('usePerformanceBond', () => { + beforeEach(() => { + mockGetPerformanceBond.mockReset(); + }); + + it('fetches performance bond for a given key ID', async () => { + const mockBond: PerformanceBond = { + keyId: 'key-100', + amountStroops: 5_000_000_000, + state: 'staked', + milestone: '500 Keys Sold', + }; + mockGetPerformanceBond.mockResolvedValue(mockBond); + + const { result } = renderHook(() => usePerformanceBond('key-100'), { + wrapper: createWrapper(), + }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + expect(mockGetPerformanceBond).toHaveBeenCalledWith('key-100'); + expect(result.current.data).toEqual(mockBond); + }); + + it('returns null when performance bond does not exist (404)', async () => { + mockGetPerformanceBond.mockResolvedValue(null); + + const { result } = renderHook(() => usePerformanceBond('key-no-bond'), { + wrapper: createWrapper(), + }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.data).toBeNull(); + }); + + it('does not run query when keyId is empty', () => { + const { result } = renderHook(() => usePerformanceBond(''), { + wrapper: createWrapper(), + }); + + expect(result.current.fetchStatus).toBe('idle'); + expect(mockGetPerformanceBond).not.toHaveBeenCalled(); + }); +}); diff --git a/src/hooks/usePerformanceBond.ts b/src/hooks/usePerformanceBond.ts new file mode 100644 index 00000000..c9ee3a62 --- /dev/null +++ b/src/hooks/usePerformanceBond.ts @@ -0,0 +1,22 @@ +import { useQuery } from '@tanstack/react-query'; +import { queryKeys } from '@/lib/queryKeys'; +import { courseService, type PerformanceBond } from '@/services/course.service'; + +/** How often performance bond status is refreshed in milliseconds (#975). */ +export const PERFORMANCE_BOND_REFRESH_INTERVAL_MS = 60_000; + +/** + * Hook to fetch performance bond status for a creator key (#975). + * + * Returns performance bond data, loading state, and error status. + * Enabled only when keyId is provided. + */ +export function usePerformanceBond(keyId: string | undefined) { + return useQuery({ + queryKey: queryKeys.creators.performanceBond(keyId ?? ''), + queryFn: () => courseService.getPerformanceBond(keyId!), + enabled: Boolean(keyId), + staleTime: PERFORMANCE_BOND_REFRESH_INTERVAL_MS, + retry: false, + }); +} diff --git a/src/lib/queryKeys.ts b/src/lib/queryKeys.ts index 91dca969..5dde8de6 100644 --- a/src/lib/queryKeys.ts +++ b/src/lib/queryKeys.ts @@ -44,6 +44,8 @@ export const queryKeys = { ['creators', creatorId, 'vesting', 'claims', wallet] as const, oraclePrice: (creatorId: string) => ['creators', creatorId, 'oracle-price'] as const, + performanceBond: (creatorId: string) => + ['creators', creatorId, 'performance-bond'] as const, keyDeployment: (keyId: string) => ['creators', 'deployment', keyId] as const, discovery: { diff --git a/src/pages/CreatorDetailPage.tsx b/src/pages/CreatorDetailPage.tsx index c1659b14..8490a850 100644 --- a/src/pages/CreatorDetailPage.tsx +++ b/src/pages/CreatorDetailPage.tsx @@ -1,244 +1,594 @@ -import React, { useState, useEffect } from 'react'; -import { useParams, useNavigate } from 'react-router'; -import { courseService } from '@/services/course.service'; -import type { Course } from '@/services/course.service'; -import BondingCurveChart from '@/components/common/BondingCurveChart'; +import { Link, useLocation, useNavigate, useParams } from 'react-router'; +import { useEffect, useState } from 'react'; +import { useCreatorDetail } from '@/hooks/useCreators'; +import { useRecentlyViewed } from '@/hooks/useRecentlyViewed'; +import { useCreatorProfileStaleIndicator } from '@/hooks/useCreatorProfileStaleIndicator'; +import CreatorBreadcrumb from '@/components/common/CreatorBreadcrumb'; import CreatorProfileHeader from '@/components/common/CreatorProfileHeader'; -import KeySupplyBadge from '@/components/common/KeySupplyBadge'; -import { formatCreatorKeyPriceDisplay } from '@/utils/keyPriceDisplay.utils'; -import { resolveCreatorKeyPriceStroops } from '@/utils/keyPriceDisplay.utils'; -import { BUY_QUANTITY_BOUNDS } from '@/constants/fees'; +import CreatorProfileInfoGrid from '@/components/common/CreatorProfileInfoGrid'; +import CreatorActivityFeed from '@/components/common/CreatorActivityFeed'; +import CreatorProfileStaleIndicator from '@/components/common/CreatorProfileStaleIndicator'; +import CreatorProfileStatRow from '@/components/common/CreatorProfileStatRow'; +import BondingCurveChart from '@/components/common/BondingCurveChart'; +import KeySimulationTool from '@/components/common/KeySimulationTool'; +import BuyCooldownCountdown from '@/components/common/BuyCooldownCountdown'; +import KeyHolderList from '@/components/common/KeyHolderList'; +import HolderConcentrationChart from '@/components/common/HolderConcentrationChart'; +import StakingRewardsSection from '@/components/common/StakingRewardsSection'; +import DeprecationNotice from '@/components/common/DeprecationNotice'; +import { isKeyDeprecated } from '@/utils/keyDeprecation.utils'; import { Button } from '@/components/ui/button'; -import { ShoppingCart } from 'lucide-react'; -import toast from 'react-hot-toast'; -import { useAccount } from 'wagmi'; -import { useNetworkMismatch } from '@/hooks/useNetworkMismatch'; -import { FormInput } from '@/components/common/FormInput'; -import { cn } from '@/lib/utils'; - -const CreatorDetailPage: React.FC = () => { +import { CreatorDashboardSkeleton } from '@/components/common/CreatorSkeleton'; +import { bpsToPercent, formatNumber } from '@/utils/numberFormat.utils'; +import { + resolveCreatorKeyPriceStroops, + formatDisplayKeyPrice, +} from '@/utils/keyPriceDisplay.utils'; +import KeyDetailPageErrorBoundary from '@/components/common/KeyDetailPageErrorBoundary'; +import { ApiError } from '@/services/api.service'; +import WatchlistButton from '@/components/common/WatchlistButton'; +import { useNavigationTiming } from '@/hooks/useNavigationTiming'; +import { useKeyHolders } from '@/hooks/useKeyHolders'; +import { useProfileStore } from '@/hooks/useProfileStore'; +import { useWalletHoldings, useTradeMutation } from '@/hooks/useWallet'; +import CoCreatorSection from '@/components/creator/CoCreatorSection'; +import ShareTwitterButton from '@/components/common/ShareTwitterButton'; +import TradeDialog from '@/components/common/TradeDialog'; +import SpreadIndicator from '@/components/common/SpreadIndicator'; +import OraclePriceIndicator from '@/components/common/OraclePriceIndicator'; +import { useKeyOraclePrice } from '@/hooks/useKeyOraclePrice'; +import showToast from '@/utils/toast.util'; +import { getSignatureErrorMessage } from '@/utils/errorHandling.utils'; +import { usePurchaseConfetti } from '@/hooks/usePurchaseConfetti'; +import { useDocumentTitle } from '@/hooks/useDocumentTitle'; +import { useKeyTwap } from '@/hooks/useKeyTwap'; +import { useKeyStats } from '@/hooks/useKeyStats'; +import { useKeyConfig } from '@/hooks/useKeyConfig'; +import KeyStatsPanel from '@/components/common/KeyStatsPanel'; +import Skeleton from '@/components/ui/skeleton'; +import { Tooltip } from '@/components/ui/tooltip'; +import KeyDeprecationBanner from '@/components/common/KeyDeprecationBanner'; +import KeyBuybackModal from '@/components/common/KeyBuybackModal'; +import type { KeyBuybackReceipt } from '@/hooks/useKeyBuyback'; +import { usePerformanceBond } from '@/hooks/usePerformanceBond'; +import PerformanceBondPanel from '@/components/common/PerformanceBondPanel'; + +function CreatorDetailPageContent() { + usePurchaseConfetti(); + const { id } = useParams<{ id: string }>(); + const location = useLocation(); const navigate = useNavigate(); - const { isConnected } = useAccount(); - const { isMismatch: isNetworkMismatch, expectedChainName } = useNetworkMismatch(); - - const [creator, setCreator] = useState(null); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - const [buyQuantity, setBuyQuantity] = useState(1); - const [isProcessing, setIsProcessing] = useState(false); + const [hasMounted, setHasMounted] = useState(false); + const [buybackModalOpen, setBuybackModalOpen] = useState(false); + const [recentSettlement, setRecentSettlement] = + useState(null); + const { + data: creator, + isLoading, + error, + isFetching, + refetch, + } = useCreatorDetail(id || ''); + useNavigationTiming('creator_profile'); + useDocumentTitle(creator ? `${creator.title} — AccessLayer` : null); useEffect(() => { - const fetchCreator = async () => { - if (!id) { - setError('Creator ID is required'); - setLoading(false); - return; - } + setHasMounted(true); + }, []); - try { - setLoading(true); - const data = await courseService.getCourse(id); - setCreator(data); - } catch (err) { - setError('Failed to load creator details'); - console.error('Error fetching creator:', err); - } finally { - setLoading(false); - } - }; + const recordVisit = useRecentlyViewed(state => state.addKey); - fetchCreator(); - }, [id]); + // Record this key as recently viewed once the detail data is available. + useEffect(() => { + if (!creator) return; + recordVisit({ + id: creator.id, + title: creator.title || creator.name || 'Unnamed creator', + price: creator.price, + priceStroops: creator.priceStroops, + change24h: creator.change24h, + category: creator.category, + avatarUri: creator.avatarUri || creator.thumbnail, + walletAddress: creator.instructorId, + }); + }, [creator, recordVisit]); - const handleBuyQuantityChange = (value: string) => { - const numValue = parseInt(value.replace(/,/g, ''), 10); - if (!isNaN(numValue) && numValue >= BUY_QUANTITY_BOUNDS.MIN_QTY && numValue <= BUY_QUANTITY_BOUNDS.MAX_QTY) { - setBuyQuantity(numValue); - } - }; + const { holders, hasNextPage, isFetchingNextPage, fetchNextPage } = + useKeyHolders(id || ''); - const handleBuy = async () => { - if (!creator) return; + // User holdings for Share to X button + const profile = useProfileStore(state => state.profile); + const userAddress = profile?.id; + const { data: holdings = [] } = useWalletHoldings(userAddress ?? ''); + const userPosition = holdings.find(h => h.creatorId === (id || '')); + const holdingsCount = userPosition?.quantity ?? 0; + // Per-user buy cooldown (#873): prefer the user's own position-level + // value; fall back to a creator-wide cooldown if the backend doesn't yet + // return a per-user one. Only shown for authenticated users. + const nextBuyAllowedAt = + userPosition?.nextBuyAllowedAt ?? creator?.nextBuyAllowedAt ?? null; + const { data: twap, isLoading: isTwapLoading } = useKeyTwap(id || ''); + const { + data: keyStats, + isLoading: isKeyStatsLoading, + isError: isKeyStatsError, + } = useKeyStats(id || ''); + // Live key config powers the bid-ask spread shown next to the buy + // action and inside the trade dialog (#951). + const { data: keyConfig, isLoading: isKeyConfigLoading } = useKeyConfig( + id || '' + ); + const spotPriceStroops = creator + ? resolveCreatorKeyPriceStroops(creator) + : null; + // External oracle price shown alongside the bonding-curve spot price so a + // significant divergence is visible before trading (#967). + const { + comparison: oracleComparison, + freshness: oracleFreshness, + source: oracleSource, + isLoading: isOracleLoading, + } = useKeyOraclePrice(id || '', { spotPriceStroops }); - if (!isConnected) { - toast.error('Please connect your wallet to purchase keys', { - duration: 4000, - }); - return; - } + // Performance bond status for creator key protection (#975) + const { + data: performanceBondData, + isLoading: isPerformanceBondLoading, + isError: isPerformanceBondError, + } = usePerformanceBond(id || ''); + const performanceBond = + performanceBondData ?? creator?.performanceBond ?? null; - if (isNetworkMismatch) { - toast.error(`Switch to ${expectedChainName} to purchase keys`, { - duration: 4000, - }); - return; - } + // Track stale data indicator + const { shouldShowBadge, handleRefetch } = useCreatorProfileStaleIndicator( + id || '', + isFetching, + () => refetch() + ); + + const [buyDialogOpen, setBuyDialogOpen] = useState(false); + const [tradeSubmitting, setTradeSubmitting] = useState(false); + const tradeMutation = useTradeMutation(userAddress ?? 'demo-wallet'); - setIsProcessing(true); + const handleConfirmBuy = async ( + amount: number, + _pricePreview?: unknown, + slippage?: { maxPriceStroops: number | null } | null + ) => { + setTradeSubmitting(true); try { - // Simulate purchase - in real implementation, this would interact with the smart contract - await new Promise(resolve => setTimeout(resolve, 1500)); - toast.success(`Successfully purchased ${buyQuantity} key(s) for ${creator.title}!`); - // Refresh creator data to get updated supply - const updatedCreator = await courseService.getCourse(id!); - setCreator(updatedCreator); - } catch (err) { - toast.error('Purchase failed. Please try again.'); - console.error('Purchase error:', err); + showToast.loading( + `Submitting buy for ${amount} key${amount === 1 ? '' : 's'}...` + ); + await tradeMutation.mutateAsync({ + creatorId: id || '', + amount, + priceStroops: creator + ? resolveCreatorKeyPriceStroops(creator) + : null, + price: creator?.price, + maxPriceStroops: slippage?.maxPriceStroops ?? null, + }); + showToast.transactionSuccess( + 'Trade confirmed', + `Bought ${formatNumber(amount)} key${amount === 1 ? '' : 's'} from ${ + creator?.title || 'Creator' + }` + ); + setBuyDialogOpen(false); + } catch (error) { + showToast.error(getSignatureErrorMessage(error)); } finally { - setIsProcessing(false); + setTradeSubmitting(false); } }; - if (loading) { + if (isLoading) { return ( -
-
Loading creator details...
-
+
+
+ +
+
); } if (error || !creator) { - return ( -
-
-

{error || 'Creator not found'}

- -
-
- ); + const is404 = + !creator || (error instanceof ApiError && error.status === 404); + if (is404) { + return ( +
+

+ Creator not found +

+

+ We couldn't find a creator with that ID. +

+ + Back to creators + +
+ ); + } + throw error; } - const currentPriceStroops = resolveCreatorKeyPriceStroops(creator); - const currentSupply = creator.creatorShareSupply || 0; + 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 ( -
- - -
-
- {/* Left column - Chart and price info */} -
-
-

Bonding Curve Price Chart

- -
+ const statItems = [ + { + label: 'Current Price', + value: formatDisplayKeyPrice(resolveCreatorKeyPriceStroops(creator)), + }, + { + label: 'Key Supply', + value: formatNumber(creator.creatorShareSupply ?? 100), + }, + { + label: '24h Volume', + value: formatDisplayKeyPrice(creator.volume24h ?? 0), + }, + { + label: 'Total Holders', + value: formatNumber( + creator.creatorShareSupply + ? Math.ceil(creator.creatorShareSupply / 2) + : 10 + ), + }, + ]; -
-

About this creator

-

- {creator.description || 'No description available.'} -

-
- - {creator.category || 'General'} - - - {creator.level || 'Open'} - -
-
-
+ const chartData = ( + creator.priceHistory && creator.priceHistory.length > 0 + ? creator.priceHistory + : [1000000, 1200000, 1500000, 1800000, 2000000] + ).map((priceStroops, index) => ({ + 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; - {/* Right column - Purchase card */} -
-
-

Purchase Keys

- -
-
- -
- {formatCreatorKeyPriceDisplay(creator)} -
-
+ const hasRealStakingData = + creator.stakingPoolBalance != null || + creator.totalStaked != null || + creator.recentFeeInflow != null; + const stakingStats = hasRealStakingData + ? { + stakingPoolBalance: creator.stakingPoolBalance, + totalStaked: creator.totalStaked, + recentFeeInflow: creator.recentFeeInflow, + } + : { + // Demo values until the key detail API returns staking pool stats. + stakingPoolBalance: 4820, + totalStaked: creator.creatorShareSupply + ? Math.floor(creator.creatorShareSupply / 4) + : 25, + recentFeeInflow: 62, + }; -
- - + return ( +
+
+ + {/* Key deprecation notice & guaranteed buyback flow (#923) */} + {isKeyDeprecated(creator) && ( + setBuybackModalOpen(true)} + recentSettlement={recentSettlement} + /> + )} +
+
+ { + if ( + window.history.length > 1 && + location.key !== 'default' + ) { + navigate(-1); + return; + } + navigate('/creators'); + }} + /> +
+ +
+ {/* 4 Stat Cards */} +
+ +
+ {/* Key Stats Panel (#952) */} + + {/* Performance Bond Status Panel (#975) */} + + {/* Deprecation Notice and Buy Action on Key Detail Page */} + {isKeyDeprecated(creator) && ( +
+ +
+ )} +
+
+

+ Key Purchase +

+

+ {isKeyDeprecated(creator) + ? 'Key is deprecated. New buys are disabled.' + : 'Purchase keys for this creator.'} +

+ {/* Configurable bid-ask spread between buy and sell price (#951) */} + + {/* Oracle reference price next to the curve spot price (#967) */} + +
+ +
+ {/* Buy Cooldown Countdown (only meaningful for authenticated users) */} + {userAddress && ( + + )} + {/* Share to X Button (only visible for authenticated holders) */} +
+ +
+ {isTwapLoading ? ( +
+
+ + +
+
+ ) : twapPrice != null ? ( +
+
+
+
+ + TWAP (24h) + + + +
- -
- -

- Min: {BUY_QUANTITY_BOUNDS.MIN_QTY}, Max: {BUY_QUANTITY_BOUNDS.MAX_QTY} -

-
- - - - {isNetworkMismatch && ( -

- Switch to {expectedChainName} to enable purchases -

- )} - - {!isConnected && ( -

- Connect your wallet to purchase keys -

- )} -
- -
-

Price Impact Preview

-
-

- Buying {buyQuantity} key{buyQuantity > 1 ? 's' : ''} will move you along the bonding curve, - increasing the price for future buyers. -

+
+ {formatDisplayKeyPrice(twapPrice)}
+ {twapDelta != null && ( + + {twapDelta < 0 ? '▼' : '▲'}{' '} + {formatDisplayKeyPrice(Math.abs(twapDelta))} vs spot + + )}
+ ) : null} + {/* Staking Rewards */} + + {/* Price Chart */} +
+

+ Price Curve +

+
-
-
+ {/* Buy Simulation Tool */} + + {/* Holder Concentration */} +
+

+ Holder Concentration +

+ +
+ {/* Fee Structure */} +
+
+

+ Fee Structure +

+ +
+ +
+ {/* Co-Creator Section */}{' '} + + {/* Key Holders */} +
+

+ Key Holders +

+ { + void fetchNextPage(); + }} + /> +
+
+

+ Activity +

+ +
+ {/* Key Buyback Modal (#923) */} + {isKeyDeprecated(creator) && ( + { + setRecentSettlement(receipt); + }} + /> + )} + + {creator && ( + + )} +
+
); -}; +} -export default CreatorDetailPage; +export default function CreatorDetailPage() { + return ( + + + + ); +} diff --git a/src/pages/__tests__/CreatorDetailPage.performanceBond.integration.test.tsx b/src/pages/__tests__/CreatorDetailPage.performanceBond.integration.test.tsx new file mode 100644 index 00000000..b2990ded --- /dev/null +++ b/src/pages/__tests__/CreatorDetailPage.performanceBond.integration.test.tsx @@ -0,0 +1,185 @@ +import type { ComponentProps, ReactNode } from 'react'; +import { render, screen, waitFor } 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, type PerformanceBond } from '@/services/course.service'; + +vi.mock('@/services/course.service', () => ({ + courseService: { + getCourse: vi.fn(), + getHoldersPage: vi.fn(), + getPerformanceBond: 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); +const mockGetHoldersPage = vi.mocked(courseService.getHoldersPage); +const mockGetPerformanceBond = vi.mocked(courseService.getPerformanceBond); + +function makeFreshQueryClient() { + return new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); +} + +function renderCreatorDetailPage(keyId = 'creator-123') { + const queryClient = makeFreshQueryClient(); + return render( + + + + } /> + + + + ); +} + +describe('CreatorDetailPage Performance Bond Integration', () => { + let consoleErrorSpy: ReturnType; + + const mockBaseCourse = { + id: 'creator-123', + title: 'Satoshi Nakamoto', + description: 'Blockchain Architect', + price: 1.5, + priceStroops: 15_000_000, + creatorShareSupply: 500, + instructorId: 'satoshin', + category: 'Crypto', + level: 'ADVANCED' as const, + isVerified: true, + }; + + beforeEach(() => { + mockGetCourse.mockReset(); + mockGetHoldersPage.mockReset(); + mockGetPerformanceBond.mockReset(); + + mockGetHoldersPage.mockResolvedValue({ + holders: [], + nextCursor: null, + }); + consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(() => { + vi.clearAllMocks(); + consoleErrorSpy.mockRestore(); + }); + + it('renders performance bond panel when key has an active performance bond', async () => { + mockGetCourse.mockResolvedValue(mockBaseCourse); + const mockBond: PerformanceBond = { + keyId: 'creator-123', + amountStroops: 50_000_000_000, // 5000 XLM + state: 'staked', + milestone: '1,000 Keys Sold', + }; + mockGetPerformanceBond.mockResolvedValue(mockBond); + + renderCreatorDetailPage(); + + expect(await screen.findByText('Satoshi Nakamoto Profile')).toBeInTheDocument(); + + await waitFor(() => { + expect(screen.getByTestId('performance-bond-panel')).toBeInTheDocument(); + }); + + expect(screen.getByTestId('performance-bond-amount')).toHaveTextContent('5,000 XLM'); + expect(screen.getByTestId('performance-bond-state')).toHaveTextContent('Staked'); + expect(screen.getByTestId('performance-bond-milestone')).toHaveTextContent('1,000 Keys Sold'); + }); + + it('does NOT render performance bond panel when key has no performance bond', async () => { + mockGetCourse.mockResolvedValue(mockBaseCourse); + mockGetPerformanceBond.mockResolvedValue(null); + + renderCreatorDetailPage(); + + expect(await screen.findByText('Satoshi Nakamoto Profile')).toBeInTheDocument(); + + await waitFor(() => { + expect(mockGetPerformanceBond).toHaveBeenCalledWith('creator-123'); + }); + + expect( + screen.queryByTestId('performance-bond-panel') + ).not.toBeInTheDocument(); + }); + + it('renders forfeited state with reason clearly on key detail page', async () => { + mockGetCourse.mockResolvedValue(mockBaseCourse); + const mockBond: PerformanceBond = { + keyId: 'creator-123', + amountStroops: 10_000_000_000, + state: 'forfeited', + milestone: '500 Active Holders', + forfeitureReason: 'Failed to fulfill content delivery agreement within 90 days.', + }; + mockGetPerformanceBond.mockResolvedValue(mockBond); + + renderCreatorDetailPage(); + + await waitFor(() => { + expect(screen.getByTestId('performance-bond-panel')).toBeInTheDocument(); + }); + + expect(screen.getByTestId('performance-bond-state')).toHaveTextContent('Forfeited'); + expect(screen.getByTestId('performance-bond-reason')).toHaveTextContent( + 'Failed to fulfill content delivery agreement within 90 days.' + ); + }); + + it('renders released state with timestamp clearly on key detail page', async () => { + mockGetCourse.mockResolvedValue(mockBaseCourse); + const mockBond: PerformanceBond = { + keyId: 'creator-123', + amountXlm: 2500, + state: 'released', + milestone: '1,000 Keys Sold', + releasedAt: '2026-09-01T10:00:00Z', + }; + mockGetPerformanceBond.mockResolvedValue(mockBond); + + renderCreatorDetailPage(); + + await waitFor(() => { + expect(screen.getByTestId('performance-bond-panel')).toBeInTheDocument(); + }); + + expect(screen.getByTestId('performance-bond-state')).toHaveTextContent('Released'); + const releaseEl = screen.getByTestId('performance-bond-released-at'); + expect(releaseEl).toBeInTheDocument(); + expect(releaseEl).toHaveTextContent(/Sep 1, 2026/i); + }); +}); diff --git a/src/services/course.service.ts b/src/services/course.service.ts index e5341166..28f7ee28 100644 --- a/src/services/course.service.ts +++ b/src/services/course.service.ts @@ -1,5 +1,5 @@ // src/services/course.service.ts -import { BaseApiService, type APIResponse } from './api.service'; +import { BaseApiService, ApiError, type APIResponse } from './api.service'; import { cacheManager } from '@/utils/cache.utils'; export interface Course { @@ -92,6 +92,8 @@ export interface Course { deprecated?: boolean; /** Optional human-readable reason surfaced in the deprecation notice. */ deprecationReason?: string | null; + /** Performance bond status for creator key protection (#975). */ + performanceBond?: PerformanceBond | null; } export interface CurveMilestone { @@ -188,6 +190,33 @@ export interface KeyOraclePrice { maxAgeSeconds?: number | null; } +export type PerformanceBondState = 'staked' | 'released' | 'forfeited'; + +/** + * Creator performance bond status (#975). + * + * Surfaces the bonded amount, current state ('staked' | 'released' | 'forfeited'), + * target milestone required for release, and state-specific details (release timestamp + * or forfeiture reason). + */ +export interface PerformanceBond { + keyId?: string; + /** Bonded amount in XLM. */ + amountXlm?: number | null; + /** Bonded amount in stroops (1 XLM = 10,000,000 stroops). */ + amountStroops?: number | null; + /** Current state of the bond: 'staked' | 'released' | 'forfeited'. */ + state: PerformanceBondState | string; + /** Maturity milestone required for bond release (e.g. "1,000 Keys Sold"). */ + milestone?: string | null; + targetMilestone?: string | null; + /** ISO timestamp when the bond was released (present for 'released' state). */ + releasedAt?: string | null; + /** Reason for forfeiture (present for 'forfeited' state). */ + forfeitureReason?: string | null; + reason?: string | null; +} + export type CourseSortOption = 'volume_desc' | 'price_asc' | 'price_desc' | 'newest'; @@ -594,6 +623,21 @@ class CourseService extends BaseApiService { throw this.handleError(error); } } + + // Get creator performance bond status - GET /keys/:keyId/performance-bond (#975) + async getPerformanceBond(keyId: string): Promise { + try { + const response = await this.api.get>( + `/keys/${keyId}/performance-bond` + ); + return response.data.data; + } catch (error: unknown) { + if (error instanceof ApiError && error.status === 404) { + return null; + } + throw this.handleError(error); + } + } } export const courseService = new CourseService(); diff --git a/src/types/html2canvas.d.ts b/src/types/html2canvas.d.ts new file mode 100644 index 00000000..78e5f3e3 --- /dev/null +++ b/src/types/html2canvas.d.ts @@ -0,0 +1,23 @@ +declare module 'html2canvas' { + export interface Html2CanvasOptions { + scale?: number; + backgroundColor?: string | null; + useCORS?: boolean; + logging?: boolean; + [key: string]: unknown; + } + + export interface HTMLCanvasElementWithDataUrl extends HTMLCanvasElement { + toDataURL(type?: string, quality?: unknown): string; + toBlob( + callback: (blob: Blob | null) => void, + type?: string, + quality?: unknown + ): void; + } + + export default function html2canvas( + element: HTMLElement, + options?: Html2CanvasOptions + ): Promise; +} diff --git a/src/utils/imageCapture.utils.ts b/src/utils/imageCapture.utils.ts index 1329b189..83e7b0b9 100644 --- a/src/utils/imageCapture.utils.ts +++ b/src/utils/imageCapture.utils.ts @@ -22,7 +22,7 @@ export async function captureElementToPng( const dataUrl = canvas.toDataURL('image/png'); const blob = await new Promise((resolve, reject) => { - canvas.toBlob(b => { + canvas.toBlob((b: Blob | null) => { if (b) { resolve(b); } else {