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
2 changes: 2 additions & 0 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -43,6 +44,7 @@ function App() {
{/* Full-width banner when contract emergency pause is active (#953) */}
<ContractPausedBanner />
<OfflineBanner />
<GlobalPauseBanner />
<Toaster
toastOptions={{
ariaProps: {
Expand Down
56 changes: 29 additions & 27 deletions src/components/common/BuySellKeyFlow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ import {
calculateTradePriceImpact,
isHighPriceImpact,
} from '@/utils/priceImpact.utils';
import CircuitBreakerStatusIndicator from '@/components/common/CircuitBreakerStatusIndicator';
import { evaluateCircuitBreakerStatus } from '@/utils/circuitBreaker.utils';
import { calculateLaunchPenalty } from '@/utils/launchPenalty.utils';
import {
calculateFeeBreakdown,
Expand All @@ -32,9 +34,8 @@ import HoldingCapIndicator from '@/components/common/HoldingCapIndicator';
import { cn } from '@/lib/utils';
import { ArrowRight } from 'lucide-react';
import { useSlippageTolerancePreference } from '@/hooks/useSlippageTolerancePreference';
import CircuitBreakerStatusIndicator from '@/components/common/CircuitBreakerStatusIndicator';
import { evaluateCircuitBreakerStatus } from '@/utils/circuitBreaker.utils';

import { useGlobalPause } from '@/hooks/useGlobalPause';
import { UnavailableAction } from '@/components/ui/unavailable-action';
export interface BuySellTradeParams {
creatorId?: string;
side: 'buy' | 'sell';
Expand Down Expand Up @@ -130,6 +131,7 @@ export const BuySellKeyFlow: React.FC<BuySellKeyFlowProps> = ({
>(null);
const [confirmationOpen, setConfirmationOpen] = useState(false);
const [internalSubmitting, setInternalSubmitting] = useState(false);
const { paused } = useGlobalPause();

const isSubmitting = externalIsSubmitting || internalSubmitting;

Expand Down Expand Up @@ -642,30 +644,30 @@ export const BuySellKeyFlow: React.FC<BuySellKeyFlowProps> = ({
)}

{/* Review / Proceed to Confirmation Button */}
<Button
type="button"
onClick={handleReviewOrder}
disabled={
!isValid ||
isSubmitting ||
isCapLimitReached ||
isCapBreached ||
isCircuitBreakerBreached ||
(impactWarningActive && !impactAcknowledged)
}
data-testid="trade-review-button"
className={cn(
'w-full rounded-xl py-3 font-bold text-sm shadow-md transition-all',
side === 'buy'
? 'bg-amber-400 hover:bg-amber-300 text-slate-950'
: 'bg-rose-500 hover:bg-rose-400 text-white'
)}
>
<span className="flex items-center justify-center gap-1.5">
{reviewButtonLabel}
<ArrowRight className="h-4 w-4" />
</span>
</Button>
<UnavailableAction disabled={paused} reason="Trading is currently paused">
<Button
type="button"
onClick={handleReviewOrder}
disabled={
!isValid ||
isSubmitting ||
(impactWarningActive && !impactAcknowledged) ||
paused
}
data-testid="trade-review-button"
className={cn(
'w-full rounded-xl py-3 font-bold text-sm shadow-md transition-all',
side === 'buy'
? 'bg-amber-400 hover:bg-amber-300 text-slate-950'
: 'bg-rose-500 hover:bg-rose-400 text-white'
)}
>
<span className="flex items-center justify-center gap-1.5">
{reviewButtonLabel}
<ArrowRight className="h-4 w-4" />
</span>
</Button>
</UnavailableAction>

{/* Confirmation Modal */}
<TradeConfirmationModal
Expand Down
13 changes: 6 additions & 7 deletions src/components/common/GlobalPauseBanner.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,11 @@
import { AlertTriangle } from 'lucide-react';
import { useGlobalPause } from '@/hooks/useGlobalPause';

interface GlobalPauseBannerProps {
pauseActivatedAt?: string;
}
const GlobalPauseBanner: React.FC = () => {
const { paused, pauseActivatedAt } = useGlobalPause();

if (!paused) return null;

const GlobalPauseBanner: React.FC<GlobalPauseBannerProps> = ({
pauseActivatedAt,
}) => {
const formatted = pauseActivatedAt
? new Date(pauseActivatedAt).toLocaleString()
: null;
Expand All @@ -20,7 +19,7 @@ const GlobalPauseBanner: React.FC<GlobalPauseBannerProps> = ({
<AlertTriangle className="size-5 shrink-0" aria-hidden="true" />
<span>
Trading is temporarily suspended across all keys. We are working to
resolve this.
resolve this. Check our <a href="https://status.accesslayer.org" className="underline underline-offset-2">status page</a> for updates.
{formatted && (
<span className="ml-2 text-xs font-normal text-white/80">
Paused since {formatted}
Expand Down
77 changes: 28 additions & 49 deletions src/components/common/PortfolioHoldingRow.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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;
Expand All @@ -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<PortfolioHoldingRowProps> = ({
Expand All @@ -61,7 +57,6 @@ export const PortfolioHoldingRow: React.FC<PortfolioHoldingRowProps> = ({
isReinvesting = false,
isRedeeming = false,
isNetworkMismatch = false,
tradeCooldown = null,
}) => {
const initialRemaining = computeRemainingLockupSeconds(position.last_buy_timestamp);
const [isLocked, setIsLocked] = useState(initialRemaining > 0);
Expand All @@ -74,6 +69,7 @@ export const PortfolioHoldingRow: React.FC<PortfolioHoldingRowProps> = ({
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.
Expand All @@ -94,9 +90,6 @@ export const PortfolioHoldingRow: React.FC<PortfolioHoldingRowProps> = ({

// #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;
Expand Down Expand Up @@ -224,45 +217,31 @@ export const PortfolioHoldingRow: React.FC<PortfolioHoldingRowProps> = ({
</Button>
)}
{onBuy && (
<TradeCooldownButton
cooldown={tradeCooldownActive}
label="Buy"
variant="default"
size="sm"
className="rounded-xl"
onClick={() => onBuy(position.creatorId)}
buttonProps={{
'data-testid': 'holding-buy-button',
disabled: isNetworkMismatch || isSubmitting || undefined,
'aria-disabled':
isNetworkMismatch || isSubmitting || undefined,
}}
/>
<UnavailableAction disabled={paused} reason="Trading is currently paused">
<Button
size="sm"
className="rounded-xl"
onClick={() => onBuy(position.creatorId)}
disabled={isNetworkMismatch || isSubmitting || paused}
data-testid="holding-buy-button"
>
Buy
</Button>
</UnavailableAction>
)}
{onSell && (
<TradeCooldownButton
cooldown={tradeCooldownActive}
label="Sell"
variant="outline"
size="sm"
className="rounded-xl"
onClick={() => onSell(position.creatorId)}
buttonProps={{
'data-testid': 'holding-sell-button',
disabled:
isLocked ||
isLiquidEmpty ||
isNetworkMismatch ||
isSubmitting ||
undefined,
'aria-disabled':
isLocked ||
isLiquidEmpty ||
isNetworkMismatch ||
isSubmitting ||
undefined,
}}
/>
<UnavailableAction disabled={paused} reason="Trading is currently paused">
<Button
size="sm"
variant="outline"
className="rounded-xl"
onClick={() => onSell(position.creatorId)}
disabled={isLocked || isLiquidEmpty || isNetworkMismatch || isSubmitting || paused}
data-testid="holding-sell-button"
>
Sell
</Button>
</UnavailableAction>
)}
</>
)}
Expand Down
12 changes: 9 additions & 3 deletions src/components/common/StakingRewardsSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -35,6 +37,8 @@ const StakingRewardsSection: React.FC<StakingRewardsSectionProps> = ({
isLoading = false,
stakeHref = '/profile?tab=staking',
}) => {
const { paused } = useGlobalPause();

if (isLoading) {
return (
<section className={CARD_CLASS} data-testid="staking-rewards-skeleton" aria-busy="true">
Expand Down Expand Up @@ -110,9 +114,11 @@ const StakingRewardsSection: React.FC<StakingRewardsSectionProps> = ({
current pool balance. Actual rewards vary with trading volume.
</p>

<Button asChild className="mt-6" data-testid="staking-cta">
<Link to={stakeHref}>Stake your keys</Link>
</Button>
<UnavailableAction disabled={paused} reason="Platform is currently paused">
<Button asChild className="mt-6" data-testid="staking-cta" disabled={paused}>
<Link to={stakeHref}>Stake your keys</Link>
</Button>
</UnavailableAction>
</section>
);
};
Expand Down
57 changes: 27 additions & 30 deletions src/components/common/TradeDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -236,6 +228,7 @@ 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();

// On open: reset state, kick off allowance check + cap fetch.
useEffect(() => {
Expand Down Expand Up @@ -1083,24 +1076,28 @@ const TradeDialog: React.FC<TradeDialogProps> = ({
>
Cancel
</Button>
<Button
type="button"
onClick={handleConfirm}
disabled={confirmDisabled}
aria-busy={isSubmitting || undefined}
title={
isPaused ? 'Trading suspended: contract is paused' : undefined
}
data-focus-order="3"
data-testid="trade-dialog-confirm"
>
<StableButtonContent
isLoading={isSubmitting}
loadingLabel="Submitting…"
<UnavailableAction disabled={paused} reason="Trading is currently paused">
<Button
type="button"
onClick={handleConfirm}
disabled={
!amountValid ||
isSubmitting ||
(side === 'buy' && (previewLoading || previewError != null)) ||
paused
}
aria-busy={isSubmitting || undefined}
data-focus-order="3"
data-testid="trade-dialog-confirm"
>
{confirmLabel}
</StableButtonContent>
</Button>
<StableButtonContent
isLoading={isSubmitting}
loadingLabel="Submitting…"
>
{confirmLabel}
</StableButtonContent>
</Button>
</UnavailableAction>
</>
);

Expand Down
Loading
Loading