diff --git a/src/App.tsx b/src/App.tsx
index 156bb11b..2fff7093 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -6,6 +6,7 @@ import { createBrowserRouter, RouterProvider } from 'react-router';
import AppErrorBoundary from './components/common/AppErrorBoundary';
import ContractPausedBanner from './components/common/ContractPausedBanner';
import OfflineBanner from './components/common/OfflineBanner';
+import GlobalPauseBanner from './components/common/GlobalPauseBanner';
import SessionExpiryWatcher from './components/common/SessionExpiryWatcher';
import { useContractPausedStore } from './hooks/useContractPausedStore';
import { routes } from './routes';
@@ -43,6 +44,7 @@ function App() {
{/* Full-width banner when contract emergency pause is active (#953) */}
+
= ({
>(null);
const [confirmationOpen, setConfirmationOpen] = useState(false);
const [internalSubmitting, setInternalSubmitting] = useState(false);
+ const { paused } = useGlobalPause();
const isSubmitting = externalIsSubmitting || internalSubmitting;
@@ -642,30 +644,30 @@ export const BuySellKeyFlow: React.FC = ({
)}
{/* Review / Proceed to Confirmation Button */}
-
+
+
+
{/* Confirmation Modal */}
{
+ const { paused, pauseActivatedAt } = useGlobalPause();
+
+ if (!paused) return null;
-const GlobalPauseBanner: React.FC = ({
- pauseActivatedAt,
-}) => {
const formatted = pauseActivatedAt
? new Date(pauseActivatedAt).toLocaleString()
: null;
@@ -20,7 +19,7 @@ const GlobalPauseBanner: React.FC = ({
Trading is temporarily suspended across all keys. We are working to
- resolve this.
+ resolve this. Check our status page for updates.
{formatted && (
Paused since {formatted}
diff --git a/src/components/common/PortfolioHoldingRow.tsx b/src/components/common/PortfolioHoldingRow.tsx
index 1bea8684..56453de2 100644
--- a/src/components/common/PortfolioHoldingRow.tsx
+++ b/src/components/common/PortfolioHoldingRow.tsx
@@ -1,7 +1,6 @@
import React, { useState } from 'react';
import { Button } from '@/components/ui/button';
import LockupCountdown from '@/components/common/LockupCountdown';
-import TradeCooldownButton from '@/components/common/TradeCooldownButton';
import ReinvestDividendDialog from '@/components/common/ReinvestDividendDialog';
import DeprecationNotice from '@/components/common/DeprecationNotice';
import RedeemKeyDialog from '@/components/common/RedeemKeyDialog';
@@ -18,11 +17,12 @@ import {
} from '@/utils/portfolioValue.utils';
import { hasUnclaimedDividend, xlmToStroops } from '@/utils/reinvestDividend.utils';
import { isKeyDeprecated } from '@/utils/keyDeprecation.utils';
-import { isActiveCooldown } from '@/utils/tradeCooldown.utils';
-import type { ActiveTradeCooldown } from '@/utils/tradeCooldown.utils';
+
import { TrendingUp, TrendingDown, Minus } from 'lucide-react';
import type { Course } from '@/services/course.service';
import { cn } from '@/lib/utils';
+import { useGlobalPause } from '@/hooks/useGlobalPause';
+import { UnavailableAction } from '@/components/ui/unavailable-action';
export interface PortfolioHoldingRowProps {
position: HeldKeyPosition;
@@ -39,11 +39,7 @@ export interface PortfolioHoldingRowProps {
isReinvesting?: boolean;
isRedeeming?: boolean;
isNetworkMismatch?: boolean;
- /**
- * Active trade cooldown for this key (#998). When present, the Buy and
- * Sell buttons are replaced by a disabled countdown until it expires.
- */
- tradeCooldown?: ActiveTradeCooldown | null;
+
}
export const PortfolioHoldingRow: React.FC = ({
@@ -61,7 +57,6 @@ export const PortfolioHoldingRow: React.FC = ({
isReinvesting = false,
isRedeeming = false,
isNetworkMismatch = false,
- tradeCooldown = null,
}) => {
const initialRemaining = computeRemainingLockupSeconds(position.last_buy_timestamp);
const [isLocked, setIsLocked] = useState(initialRemaining > 0);
@@ -74,6 +69,7 @@ export const PortfolioHoldingRow: React.FC = ({
const hasDividends = hasUnclaimedDividend(position.unclaimedDividend);
const keyPriceStroops = resolveCreatorKeyPriceStroops(position);
const deprecated = isKeyDeprecated(creator);
+ const { paused } = useGlobalPause();
// #935 — unrealised P&L for this position: what the keys would fetch if sold
// at the current bonding-curve sell price, less what was paid for them.
@@ -94,9 +90,6 @@ export const PortfolioHoldingRow: React.FC = ({
// #998 — the per-key trade cooldown disables both buy and sell for the
// window configured by the creator; the countdown drives the labels.
- const tradeCooldownActive = isActiveCooldown(tradeCooldown)
- ? (tradeCooldown as ActiveTradeCooldown)
- : null;
const handleConfirmReinvest = async () => {
if (!onReinvest) return;
@@ -224,45 +217,31 @@ export const PortfolioHoldingRow: React.FC = ({
)}
{onBuy && (
- onBuy(position.creatorId)}
- buttonProps={{
- 'data-testid': 'holding-buy-button',
- disabled: isNetworkMismatch || isSubmitting || undefined,
- 'aria-disabled':
- isNetworkMismatch || isSubmitting || undefined,
- }}
- />
+
+
+
)}
{onSell && (
- onSell(position.creatorId)}
- buttonProps={{
- 'data-testid': 'holding-sell-button',
- disabled:
- isLocked ||
- isLiquidEmpty ||
- isNetworkMismatch ||
- isSubmitting ||
- undefined,
- 'aria-disabled':
- isLocked ||
- isLiquidEmpty ||
- isNetworkMismatch ||
- isSubmitting ||
- undefined,
- }}
- />
+
+
+
)}
>
)}
diff --git a/src/components/common/StakingRewardsSection.tsx b/src/components/common/StakingRewardsSection.tsx
index 4c17cd27..4b542a0d 100644
--- a/src/components/common/StakingRewardsSection.tsx
+++ b/src/components/common/StakingRewardsSection.tsx
@@ -8,6 +8,8 @@ import {
hasStakingActivity,
type StakingPoolStats,
} from '@/utils/stakingRewards.utils';
+import { useGlobalPause } from '@/hooks/useGlobalPause';
+import { UnavailableAction } from '@/components/ui/unavailable-action';
export interface StakingRewardsSectionProps extends StakingPoolStats {
/** Whether the key detail data is still loading. */
@@ -35,6 +37,8 @@ const StakingRewardsSection: React.FC = ({
isLoading = false,
stakeHref = '/profile?tab=staking',
}) => {
+ const { paused } = useGlobalPause();
+
if (isLoading) {
return (
@@ -110,9 +114,11 @@ const StakingRewardsSection: React.FC = ({
current pool balance. Actual rewards vary with trading volume.
-
+
+
+
);
};
diff --git a/src/components/common/TradeDialog.tsx b/src/components/common/TradeDialog.tsx
index 991cf639..7fa3fc1c 100644
--- a/src/components/common/TradeDialog.tsx
+++ b/src/components/common/TradeDialog.tsx
@@ -70,20 +70,12 @@ import {
computeSlippageBounds,
type SlippageBounds,
} from '@/utils/slippageTolerance.utils';
-import {
- useAllowanceStore,
- selectNeedsApproval,
-} from '@/hooks/useAllowanceStore';
-import {
- useHoldingCapStore,
- isAtHoldingCap,
- remainingCapacity,
-} from '@/hooks/useHoldingCapStore';
-import {
- useContractPausedStore,
- selectIsPaused,
-} from '@/hooks/useContractPausedStore';
+import { useGlobalPause } from '@/hooks/useGlobalPause';
+import { UnavailableAction } from '@/components/ui/unavailable-action';
+import { useContractPausedStore, selectIsPaused } from '@/hooks/useContractPausedStore';
+import { useAllowanceStore, selectNeedsApproval } from '@/hooks/useAllowanceStore';
import { useShallow } from 'zustand/react/shallow';
+import { useHoldingCapStore, isAtHoldingCap, remainingCapacity } from '@/hooks/useHoldingCapStore';
export type TradeSide = 'buy' | 'sell' | 'stake' | 'transfer';
@@ -236,6 +228,7 @@ 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();
// On open: reset state, kick off allowance check + cap fetch.
useEffect(() => {
@@ -1083,24 +1076,28 @@ const TradeDialog: React.FC = ({
>
Cancel
-
+
+ {confirmLabel}
+
+
+
>
);
diff --git a/src/hooks/useGlobalPause.ts b/src/hooks/useGlobalPause.ts
index ed38d7ef..e06930db 100644
--- a/src/hooks/useGlobalPause.ts
+++ b/src/hooks/useGlobalPause.ts
@@ -5,37 +5,61 @@ export interface ProtocolStatus {
pauseActivatedAt?: string;
}
-const POLL_INTERVAL_MS = 60_000;
+const POLL_INTERVAL_MS = 30_000;
+
+let globalPaused = false;
+let globalPauseActivatedAt: string | undefined;
+let listeners: Array<() => void> = [];
+
+const notify = () => listeners.forEach(l => l());
+
+const check = async () => {
+ try {
+ const res = await fetch('/protocol/status');
+ if (!res.ok) return;
+ const data: ProtocolStatus = await res.json();
+ if (
+ globalPaused !== data.globalTradingPaused ||
+ globalPauseActivatedAt !== data.pauseActivatedAt
+ ) {
+ globalPaused = data.globalTradingPaused;
+ globalPauseActivatedAt = data.pauseActivatedAt;
+ notify();
+ }
+ } catch {
+ // silently ignore — will retry next poll
+ }
+};
+
+let pollTimer: number | undefined;
export function useGlobalPause() {
- const [paused, setPaused] = useState(false);
- const [pauseActivatedAt, setPauseActivatedAt] = useState<
- string | undefined
- >();
+ const [state, setState] = useState({
+ paused: globalPaused,
+ pauseActivatedAt: globalPauseActivatedAt,
+ });
useEffect(() => {
- let active = true;
-
- const check = async () => {
- try {
- const res = await fetch('/protocol/status');
- if (!res.ok) return;
- const data: ProtocolStatus = await res.json();
- if (!active) return;
- setPaused(data.globalTradingPaused);
- setPauseActivatedAt(data.pauseActivatedAt);
- } catch {
- // silently ignore — will retry next poll
- }
- };
+ const listener = () =>
+ setState({
+ paused: globalPaused,
+ pauseActivatedAt: globalPauseActivatedAt,
+ });
+ listeners.push(listener);
+
+ if (listeners.length === 1) {
+ check();
+ pollTimer = window.setInterval(check, POLL_INTERVAL_MS);
+ }
- check();
- const id = window.setInterval(check, POLL_INTERVAL_MS);
return () => {
- active = false;
- window.clearInterval(id);
+ listeners = listeners.filter(l => l !== listener);
+ if (listeners.length === 0 && pollTimer !== undefined) {
+ window.clearInterval(pollTimer);
+ pollTimer = undefined;
+ }
};
}, []);
- return { paused, pauseActivatedAt };
+ return state;
}
diff --git a/src/pages/AtomicSwapCreatePage.tsx b/src/pages/AtomicSwapCreatePage.tsx
index cc80aa0a..e23b9495 100644
--- a/src/pages/AtomicSwapCreatePage.tsx
+++ b/src/pages/AtomicSwapCreatePage.tsx
@@ -15,6 +15,8 @@ import PortfolioHoldingRow from '@/components/common/PortfolioHoldingRow';
import HoldingsEmptyState from '@/components/common/HoldingsEmptyState';
import CreatorSkeleton from '@/components/common/CreatorSkeleton';
import { useNetworkMismatch } from '@/hooks/useNetworkMismatch';
+import { useGlobalPause } from '@/hooks/useGlobalPause';
+import { UnavailableAction } from '@/components/ui/unavailable-action';
const DEMO_WALLET_ADDRESS = 'demo-wallet-address';
@@ -26,6 +28,7 @@ export default function AtomicSwapCreatePage() {
const [creators, setCreators] = useState([]);
const [isLoading, setIsLoading] = useState(true);
const [formOpen, setFormOpen] = useState(false);
+ const { paused } = useGlobalPause();
const { data: cachedHoldings = [] } = useWalletHoldings(activeWalletAddress);
@@ -166,17 +169,19 @@ export default function AtomicSwapCreatePage() {
walletAddress={activeWalletAddress}
isSubmitting={isNetworkMismatch}
/>
-
-
- Create Swap Proposal
-
+
+
+
+ Create Swap Proposal
+
+
The counterparty will need to connect their wallet and accept the proposal to execute the atomic swap.