Skip to content
Merged
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
7 changes: 6 additions & 1 deletion src/components/common/BuySellKeyFlow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down Expand Up @@ -110,6 +112,7 @@ export const BuySellKeyFlow: React.FC<BuySellKeyFlowProps> = ({
onSuccess,
onError,
isSubmitting: externalIsSubmitting = false,
tradingPaused = false,
className,
}) => {
const effectiveHoldingCap = useMemo(
Expand All @@ -131,7 +134,9 @@ export const BuySellKeyFlow: React.FC<BuySellKeyFlowProps> = ({
>(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;

Expand Down
30 changes: 30 additions & 0 deletions src/components/common/KeyTradingPausedBanner.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div
role="alert"
data-testid="key-trading-paused-banner"
className={cn(
'flex items-center gap-2.5 rounded-xl border border-yellow-500/30 bg-yellow-500/10 px-4 py-3 text-sm font-medium text-yellow-300',
className
)}
>
<AlertTriangle className="size-4 shrink-0" aria-hidden="true" />
<span>Trading is temporarily paused for this key</span>
</div>
);
}
7 changes: 6 additions & 1 deletion src/components/common/TradeDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -166,6 +168,7 @@ const TradeDialog: React.FC<TradeDialogProps> = ({
circuitBreakerThresholdPercent,
circuitBreakerThresholdBps,
requireConfirmation = false,
tradingPaused = false,
xlmUsdRate = null,
onOpenChange,
onConfirm,
Expand Down Expand Up @@ -228,7 +231,9 @@ const TradeDialog: React.FC<TradeDialogProps> = ({
const previewAbortControllerRef = useRef<AbortController | null>(null);
// Capture whatever had focus right before dialog opened; restore on close.
const triggerElementRef = useRef<HTMLElement | null>(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(() => {
Expand Down
60 changes: 60 additions & 0 deletions src/components/common/__tests__/TradeDialog.tradingPaused.test.tsx
Original file line number Diff line number Diff line change
@@ -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<React.ComponentProps<typeof TradeDialog>> = {}
) {
return render(
<TradeDialog
open={true}
side="buy"
creatorName="Alice"
availableHoldings={10}
keyPriceStroops={500_000}
currentSupply={100}
onOpenChange={vi.fn()}
onConfirm={vi.fn()}
{...overrides}
/>
);
}

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();
});
});
15 changes: 15 additions & 0 deletions src/pages/CreatorDetailPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -485,6 +487,8 @@ function CreatorDetailPageContent() {
/>
)}

<KeyTradingPausedBanner tradingPaused={creator.tradingPaused} />

<MergeProposalBanner
sourceKeyId={id || ''}
holdingsCount={holdingsCount}
Expand Down Expand Up @@ -646,6 +650,16 @@ function CreatorDetailPageContent() {
>
Buy Disabled (Deprecated)
</Button>
) : creator.tradingPaused ? (
<UnavailableAction disabled reason="Trading is currently paused">
<Button
type="button"
data-testid="key-detail-buy-button"
className="min-h-11 w-full rounded-xl bg-amber-400 font-bold text-slate-950 hover:bg-amber-300 sm:h-10 sm:min-h-0 sm:w-auto"
>
Buy Key
</Button>
</UnavailableAction>
) : buyDisabledReason ? (
<Tooltip content={buyDisabledReason}>
<span
Expand Down Expand Up @@ -938,6 +952,7 @@ function CreatorDetailPageContent() {
onConfirm={handleConfirmBuy}
isSubmitting={tradeSubmitting}
requireConfirmation={true}
tradingPaused={creator.tradingPaused}
/>
)}

Expand Down
Original file line number Diff line number Diff line change
@@ -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<typeof import('@/hooks/useCreators')>();
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(
<QueryClientProvider client={createTestQueryClient()}>
<MemoryRouter initialEntries={['/creators/creator-1']}>
<Routes>
<Route path="/creators/:id" element={<CreatorDetailPage />} />
</Routes>
</MemoryRouter>
</QueryClientProvider>
);

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<typeof useCreatorDetail>);

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<typeof useCreatorDetail>);

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<typeof useCreatorDetail>);

renderPage();

await waitFor(() => {
expect(screen.getByTestId('key-detail-buy-button')).toBeInTheDocument();
});

expect(
screen.queryByTestId('key-trading-paused-banner')
).not.toBeInTheDocument();
});
});
5 changes: 5 additions & 0 deletions src/services/course.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
Loading