= ({
)}
+ {bio && (
+
+ {bio}
+
+ )}
+
{metaItems.length > 0 && (
{metaItems.map((item, index) => (
diff --git a/src/components/common/DeployedCreatorKeyCard.tsx b/src/components/common/DeployedCreatorKeyCard.tsx
index 2e5afd57..dc6edf00 100644
--- a/src/components/common/DeployedCreatorKeyCard.tsx
+++ b/src/components/common/DeployedCreatorKeyCard.tsx
@@ -150,4 +150,5 @@ const DeployedCreatorKeyCard: React.FC = ({
);
};
+export { DeployedCreatorKeyCard as KeyCard };
export default DeployedCreatorKeyCard;
diff --git a/src/components/common/KeyFreezeBanner.tsx b/src/components/common/KeyFreezeBanner.tsx
new file mode 100644
index 00000000..b326db0f
--- /dev/null
+++ b/src/components/common/KeyFreezeBanner.tsx
@@ -0,0 +1,124 @@
+import React, { useEffect, useMemo, useState } from 'react';
+import { AlertTriangle, ExternalLink } from 'lucide-react';
+import { cn } from '@/lib/utils';
+import { formatAbsoluteDateTime } from '@/utils/time.utils';
+
+export interface KeyFreezeBannerProps {
+ isFrozen?: boolean;
+ reason?: string | null;
+ expiry?: string | number | null;
+ adminContactUrl?: string;
+ onExpiryReached?: () => void;
+ className?: string;
+}
+
+export function formatFreezeExpiry(expiry?: string | number | null): string {
+ if (!expiry) return 'indefinite';
+
+ const timestamp = typeof expiry === 'number' ? expiry : Date.parse(expiry);
+ if (isNaN(timestamp)) return 'indefinite';
+
+ const iso = new Date(timestamp).toISOString();
+ return formatAbsoluteDateTime(iso) ?? new Date(timestamp).toLocaleString();
+}
+
+/**
+ * Orange warning banner displayed below key header when a key freeze is active (#1121).
+ * Displays freeze reason, expiry timestamp (or 'indefinite'), and admin contact link.
+ * Automatically dismisses when expiry time is reached.
+ */
+export default function KeyFreezeBanner({
+ isFrozen = false,
+ reason,
+ expiry,
+ adminContactUrl = 'mailto:admin@accesslayer.org',
+ onExpiryReached,
+ className,
+}: KeyFreezeBannerProps) {
+ const [dismissed, setDismissed] = useState(false);
+
+ const expiryTimestamp = useMemo(() => {
+ if (!expiry) return null;
+ const ts = typeof expiry === 'number' ? expiry : Date.parse(expiry);
+ return isNaN(ts) ? null : ts;
+ }, [expiry]);
+
+ const formattedExpiry = useMemo(
+ () => formatFreezeExpiry(expiry),
+ [expiry]
+ );
+
+ useEffect(() => {
+ if (!isFrozen || !expiryTimestamp) return;
+
+ const remainingMs = expiryTimestamp - Date.now();
+ if (remainingMs <= 0) {
+ setDismissed(true);
+ onExpiryReached?.();
+ return;
+ }
+
+ const timer = setTimeout(() => {
+ setDismissed(true);
+ onExpiryReached?.();
+ }, remainingMs);
+
+ return () => clearTimeout(timer);
+ }, [isFrozen, expiryTimestamp, onExpiryReached]);
+
+ if (!isFrozen || dismissed) {
+ return null;
+ }
+
+ return (
+
+ );
+}
diff --git a/src/components/common/RootLayout.tsx b/src/components/common/RootLayout.tsx
index 7fa9d6a1..b2a3fb95 100644
--- a/src/components/common/RootLayout.tsx
+++ b/src/components/common/RootLayout.tsx
@@ -1,17 +1,23 @@
import { Outlet } from 'react-router';
import MobileBottomNav from '@/components/common/MobileBottomNav';
+import BundleCartDrawer from '@/components/common/BundleCartDrawer';
+import BundleCartTrigger from '@/components/common/BundleCartTrigger';
/**
* Root layout shared by all routes.
*
* Renders the matched child route via and mounts the
* MobileBottomNav globally so it is always available on mobile viewports
- * without each page having to include it individually.
+ * without each page having to include it individually. Mounts the global
+ * bundle cart drawer and trigger across all routes (#1118).
*/
export default function RootLayout() {
return (
<>
+ {/* Bundle cart drawer & floating trigger (#1118) */}
+
+
{/* Fixed bottom navigation bar — visible only on viewports < 768px (md:hidden) */}
>
diff --git a/src/components/common/TradeDialog.tsx b/src/components/common/TradeDialog.tsx
index ec09eda4..ee8aa464 100644
--- a/src/components/common/TradeDialog.tsx
+++ b/src/components/common/TradeDialog.tsx
@@ -136,6 +136,9 @@ export interface TradeDialogProps {
requireConfirmation?: boolean;
/** True when an admin has paused trading for this specific key (#777). */
tradingPaused?: boolean;
+ /** True when this key is frozen (#1121). */
+ isFrozen?: boolean;
+ freezeReason?: string | null;
/** Optional XLM/USD spot rate for the confirmation fee USD equivalent (#994). */
xlmUsdRate?: number | null;
onOpenChange: (open: boolean) => void;
@@ -174,6 +177,8 @@ const TradeDialog: React.FC = ({
circuitBreakerThresholdBps,
requireConfirmation = false,
tradingPaused = false,
+ isFrozen = false,
+ freezeReason = null,
xlmUsdRate = null,
onOpenChange,
onConfirm,
@@ -237,8 +242,11 @@ const TradeDialog: React.FC = ({
// Capture whatever had focus right before dialog opened; restore on close.
const triggerElementRef = useRef(null);
const { paused: globalPaused } = useGlobalPause();
- // Trading is blocked by a platform-wide pause OR a pause on this key (#777).
- const paused = globalPaused || tradingPaused;
+ // Trading is blocked by a platform-wide pause, a pause on this key (#777), or key freeze (#1121).
+ const paused = globalPaused || tradingPaused || isFrozen;
+ const pauseReason = isFrozen
+ ? `Trading is frozen${freezeReason ? `: ${freezeReason}` : ' for this key'}`
+ : 'Trading is currently paused';
// On open: reset state, kick off allowance check + cap fetch.
useEffect(() => {
@@ -358,7 +366,8 @@ const TradeDialog: React.FC = ({
if (!normalized) return 'Please enter an amount.';
if (!Number.isFinite(parsedAmount))
return 'Amount must be a valid number.';
- if (parsedAmount <= 0) return 'Amount must be greater than zero.';
+ if (isFrozen)
+ return `Trading is currently suspended because this key is frozen${freezeReason ? `: ${freezeReason}` : ''}.`;
if (isPaused)
return 'Trading is currently suspended because the contract is paused.';
if (side === 'buy' && atCap)
@@ -386,6 +395,8 @@ const TradeDialog: React.FC = ({
maxBuyable,
maxBuyQuantity,
isPaused,
+ isFrozen,
+ freezeReason,
]);
const amountValid = validationError === null;
@@ -482,16 +493,20 @@ const TradeDialog: React.FC = ({
? 'Circuit Breaker Tripped'
: atCap
? 'Cap reached'
- : isPaused
- ? 'Contract paused'
- : 'Confirm buy'
- : isPaused
- ? 'Contract paused'
- : side === 'sell'
- ? 'Confirm sell'
- : side === 'stake'
- ? 'Confirm stake'
- : 'Confirm transfer';
+ : isFrozen
+ ? 'Key frozen'
+ : isPaused
+ ? 'Contract paused'
+ : 'Confirm buy'
+ : isFrozen
+ ? 'Key frozen'
+ : isPaused
+ ? 'Contract paused'
+ : side === 'sell'
+ ? 'Confirm sell'
+ : side === 'stake'
+ ? 'Confirm stake'
+ : 'Confirm transfer';
const estimatedNetworkFee = formatTransactionFeeDisplay(
networkFeeEstimate.status === 'success'
@@ -916,12 +931,12 @@ const TradeDialog: React.FC = ({
setAdjustmentNote(null);
}}
onBlur={handleBlur}
- disabled={isSubmitting || atCap || isPaused}
+ disabled={isSubmitting || atCap || isPaused || paused}
className={cn(
'w-full rounded-xl border bg-white/[0.04] px-3 py-2 pr-16 text-white outline-none transition-colors',
'border-white/10 focus:border-amber-500/50 focus:ring-2 focus:ring-amber-500/15',
showError ? 'border-red-500/60' : '',
- atCap || isPaused ? 'opacity-40 cursor-not-allowed' : ''
+ atCap || isPaused || paused ? 'opacity-40 cursor-not-allowed' : ''
)}
aria-label="Trade amount"
aria-describedby={
@@ -936,7 +951,7 @@ const TradeDialog: React.FC = ({
data-testid="trade-dialog-max-button"
onClick={handleMaxClick}
disabled={
- isSubmitting || (side === 'sell' && availableHoldings <= 0)
+ isSubmitting || paused || (side === 'sell' && availableHoldings <= 0)
}
className="absolute right-2 rounded-md border border-amber-400/30 bg-amber-400/10 px-2 py-0.5 text-xs font-bold text-amber-300 transition-colors hover:bg-amber-400/20 active:scale-95 disabled:pointer-events-none disabled:opacity-40"
>
@@ -1112,7 +1127,7 @@ const TradeDialog: React.FC = ({
>
Cancel
-
+