diff --git a/src/components/common/BuySellKeyFlow.tsx b/src/components/common/BuySellKeyFlow.tsx index 8fa404fa..e54912b4 100644 --- a/src/components/common/BuySellKeyFlow.tsx +++ b/src/components/common/BuySellKeyFlow.tsx @@ -75,6 +75,8 @@ export interface BuySellKeyFlowProps { onSuccess?: (params: BuySellTradeParams) => void; onError?: (error: unknown) => void; isSubmitting?: boolean; + /** True when an admin has paused trading for this specific key (#777). */ + tradingPaused?: boolean; className?: string; } @@ -110,6 +112,7 @@ export const BuySellKeyFlow: React.FC = ({ onSuccess, onError, isSubmitting: externalIsSubmitting = false, + tradingPaused = false, className, }) => { const effectiveHoldingCap = useMemo( @@ -131,7 +134,9 @@ export const BuySellKeyFlow: React.FC = ({ >(null); const [confirmationOpen, setConfirmationOpen] = useState(false); const [internalSubmitting, setInternalSubmitting] = useState(false); - const { paused } = useGlobalPause(); + const { paused: globalPaused } = useGlobalPause(); + // Trading is blocked by a platform-wide pause OR a pause on this key (#777). + const paused = globalPaused || tradingPaused; const isSubmitting = externalIsSubmitting || internalSubmitting; diff --git a/src/components/common/KeyTradingPausedBanner.tsx b/src/components/common/KeyTradingPausedBanner.tsx new file mode 100644 index 00000000..2fc3d345 --- /dev/null +++ b/src/components/common/KeyTradingPausedBanner.tsx @@ -0,0 +1,30 @@ +import { AlertTriangle } from 'lucide-react'; +import { cn } from '@/lib/utils'; + +export interface KeyTradingPausedBannerProps { + tradingPaused?: boolean; + className?: string; +} + +export default function KeyTradingPausedBanner({ + tradingPaused, + className, +}: KeyTradingPausedBannerProps) { + if (!tradingPaused) { + return null; + } + + return ( +
+
+ ); +} diff --git a/src/components/common/TradeDialog.tsx b/src/components/common/TradeDialog.tsx index 7fa3fc1c..71416bda 100644 --- a/src/components/common/TradeDialog.tsx +++ b/src/components/common/TradeDialog.tsx @@ -129,6 +129,8 @@ export interface TradeDialogProps { circuitBreakerThresholdBps?: number | null; /** Whether to display the confirmation modal step before submission (#919). Defaults to false. */ requireConfirmation?: boolean; + /** True when an admin has paused trading for this specific key (#777). */ + tradingPaused?: boolean; /** Optional XLM/USD spot rate for the confirmation fee USD equivalent (#994). */ xlmUsdRate?: number | null; onOpenChange: (open: boolean) => void; @@ -166,6 +168,7 @@ const TradeDialog: React.FC = ({ circuitBreakerThresholdPercent, circuitBreakerThresholdBps, requireConfirmation = false, + tradingPaused = false, xlmUsdRate = null, onOpenChange, onConfirm, @@ -228,7 +231,9 @@ const TradeDialog: React.FC = ({ const previewAbortControllerRef = useRef(null); // Capture whatever had focus right before dialog opened; restore on close. const triggerElementRef = useRef(null); - const { paused } = useGlobalPause(); + const { paused: globalPaused } = useGlobalPause(); + // Trading is blocked by a platform-wide pause OR a pause on this key (#777). + const paused = globalPaused || tradingPaused; // On open: reset state, kick off allowance check + cap fetch. useEffect(() => { diff --git a/src/components/common/__tests__/TradeDialog.tradingPaused.test.tsx b/src/components/common/__tests__/TradeDialog.tradingPaused.test.tsx new file mode 100644 index 00000000..e2090325 --- /dev/null +++ b/src/components/common/__tests__/TradeDialog.tradingPaused.test.tsx @@ -0,0 +1,60 @@ +/** + * Unit tests for TradeDialog respecting the per-key trading pause (#777). + * + * When tradingPaused is true the confirm button must be disabled and the + * UnavailableAction tooltip must display "Trading is currently paused". + */ +import { describe, expect, it, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import TradeDialog from '@/components/common/TradeDialog'; + +describe('TradeDialog – per-key trading pause (#777)', () => { + function renderDialog( + overrides: Partial> = {} + ) { + return render( + + ); + } + + it.each(['buy', 'sell'] as const)( + 'disables the confirm button and shows the pause tooltip on side="%s" when tradingPaused is true', + (side) => { + renderDialog({ side, tradingPaused: true }); + + const input = screen.getByTestId( + 'trade-dialog-amount' + ) as HTMLInputElement; + fireEvent.change(input, { target: { value: '1' } }); + + // Confirm button must be disabled. + const confirmButton = screen.getByTestId('trade-dialog-confirm'); + expect(confirmButton).toBeDisabled(); + + // The UnavailableAction tooltip must contain the pause reason. + const tooltips = screen.getAllByRole('tooltip'); + const pauseTooltip = tooltips.find((t) => + t.textContent?.includes('Trading is currently paused') + ); + expect(pauseTooltip).toBeDefined(); + } + ); + + it('does not show the pause tooltip on side="sell" when tradingPaused is false', () => { + renderDialog({ side: 'sell', tradingPaused: false }); + + expect( + screen.queryByText('Trading is currently paused') + ).not.toBeInTheDocument(); + }); +}); diff --git a/src/pages/CreatorDetailPage.tsx b/src/pages/CreatorDetailPage.tsx index fc767eb1..33065b15 100644 --- a/src/pages/CreatorDetailPage.tsx +++ b/src/pages/CreatorDetailPage.tsx @@ -63,8 +63,10 @@ import { useKeyConfig } from '@/hooks/useKeyConfig'; import KeyStatsPanel from '@/components/common/KeyStatsPanel'; import Skeleton from '@/components/ui/skeleton'; import { Tooltip } from '@/components/ui/tooltip'; +import { UnavailableAction } from '@/components/ui/unavailable-action'; import GraduatedCurveMilestoneChart from '@/components/common/GraduatedCurveMilestoneChart'; import KeyDeprecationBanner from '@/components/common/KeyDeprecationBanner'; +import KeyTradingPausedBanner from '@/components/common/KeyTradingPausedBanner'; import MergeProposalBanner from '@/components/common/MergeProposalBanner'; import KeyBuybackModal from '@/components/common/KeyBuybackModal'; import type { KeyBuybackReceipt } from '@/hooks/useKeyBuyback'; @@ -485,6 +487,8 @@ function CreatorDetailPageContent() { /> )} + + Buy Disabled (Deprecated) + ) : creator.tradingPaused ? ( + + + ) : buyDisabledReason ? ( )} diff --git a/src/pages/__tests__/CreatorDetailPage.tradingPaused.integration.test.tsx b/src/pages/__tests__/CreatorDetailPage.tradingPaused.integration.test.tsx new file mode 100644 index 00000000..3016b1f4 --- /dev/null +++ b/src/pages/__tests__/CreatorDetailPage.tradingPaused.integration.test.tsx @@ -0,0 +1,161 @@ +/** + * Integration tests for the per-key trading pause banner and disabled Buy + * button on the Creator Detail Page (#777). + */ +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, expect, it, vi, beforeEach } from 'vitest'; +import CreatorDetailPage from '../CreatorDetailPage'; +import { useCreatorDetail } from '@/hooks/useCreators'; +import { MemoryRouter, Route, Routes } from 'react-router'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; + +vi.mock('@/hooks/useCreators', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useCreatorDetail: vi.fn(), + useSetCoCreator: vi.fn(() => ({ mutate: vi.fn(), isPending: false })), + }; +}); + +vi.mock('@/hooks/useCreatorProfileStaleIndicator', () => ({ + useCreatorProfileStaleIndicator: () => ({ + shouldShowBadge: false, + handleRefetch: vi.fn(), + }), +})); + +vi.mock('@/hooks/useNavigationTiming', () => ({ + useNavigationTiming: vi.fn(), +})); + +vi.mock('@/hooks/usePurchaseConfetti', () => ({ + usePurchaseConfetti: vi.fn(), +})); + +const createTestQueryClient = () => + new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }); + +const renderPage = () => + render( + + + + } /> + + + + ); + +describe('CreatorDetailPage trading-paused banner & buy button (#777)', () => { + const baseCreator = { + id: 'creator-1', + title: 'Alex Rivers', + description: 'Digital Artist & Illustrator', + price: 0.1, + priceStroops: 1_000_000, + instructorId: 'inst-1', + socialHandle: 'alexrivers', + isVerified: true, + thumbnail: 'https://example.com/avatar.jpg', + category: 'Art', + level: 'BEGINNER' as const, + creatorFeeBps: 0, + protocolFeeBps: 0, + creatorShareSupply: 50, + volume24h: 10_000_000, + }; + + beforeEach(() => { + vi.clearAllMocks(); + window.localStorage.clear(); + }); + + it('shows the trading-paused banner, disables Buy, and shows the pause tooltip when tradingPaused is true', async () => { + vi.mocked(useCreatorDetail).mockReturnValue({ + data: { ...baseCreator, tradingPaused: true }, + isLoading: false, + error: null, + isFetching: false, + refetch: vi.fn(), + } as unknown as ReturnType); + + renderPage(); + + // Banner is visible with the correct text. + const banner = await screen.findByTestId('key-trading-paused-banner'); + expect(banner).toBeInTheDocument(); + expect(banner).toHaveTextContent( + 'Trading is temporarily paused for this key' + ); + + // Buy button is present and disabled. + await waitFor(() => { + expect(screen.getByTestId('key-detail-buy-button')).toBeDisabled(); + }); + + // Hover over the buy button area to surface the tooltip. + const buyButton = screen.getByTestId('key-detail-buy-button'); + await userEvent.hover(buyButton.closest('[aria-disabled]') ?? buyButton); + + // The UnavailableAction tooltip contains the pause reason. + await waitFor(() => { + const tooltips = screen.getAllByRole('tooltip'); + const pauseTooltip = tooltips.find((t) => + t.textContent?.includes('Trading is currently paused') + ); + expect(pauseTooltip).toBeDefined(); + }); + }); + + it('does not show the banner when tradingPaused is false', async () => { + vi.mocked(useCreatorDetail).mockReturnValue({ + data: { ...baseCreator, tradingPaused: false }, + isLoading: false, + error: null, + isFetching: false, + refetch: vi.fn(), + } as unknown as ReturnType); + + renderPage(); + + // Wait for the page to settle. + await waitFor(() => { + expect(screen.getByTestId('key-detail-buy-button')).toBeInTheDocument(); + }); + + expect( + screen.queryByTestId('key-trading-paused-banner') + ).not.toBeInTheDocument(); + expect( + screen.queryByText('Trading is currently paused') + ).not.toBeInTheDocument(); + }); + + it('does not show the banner when tradingPaused is missing', async () => { + vi.mocked(useCreatorDetail).mockReturnValue({ + data: { ...baseCreator }, + isLoading: false, + error: null, + isFetching: false, + refetch: vi.fn(), + } as unknown as ReturnType); + + renderPage(); + + await waitFor(() => { + expect(screen.getByTestId('key-detail-buy-button')).toBeInTheDocument(); + }); + + expect( + screen.queryByTestId('key-trading-paused-banner') + ).not.toBeInTheDocument(); + }); +}); diff --git a/src/services/course.service.ts b/src/services/course.service.ts index 1313be2f..3fb6573f 100644 --- a/src/services/course.service.ts +++ b/src/services/course.service.ts @@ -120,6 +120,11 @@ export interface Course { deprecated?: boolean; /** Optional human-readable reason surfaced in the deprecation notice. */ deprecationReason?: string | null; + /** + * Whether an admin has paused trading for this specific key (#777). + * While true, buying and selling must be disabled in the UI. + */ + tradingPaused?: boolean; /** Performance bond status for creator key protection (#975). */ performanceBond?: PerformanceBond | null; /** Key-level circuit breaker price impact threshold in basis points. */