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
73 changes: 73 additions & 0 deletions frontend/src/components/HighLatencyBanner.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import React, { useState } from "react";
import { useNetworkQuality, type NetworkQuality } from "../hooks/useNetworkQuality";
import { AlertTriangle, Clock, Wifi, X } from "./icons";

const CONFIG: Record<NetworkQuality, { color: string; bg: string; label: string; icon: React.ReactNode }> = {
fast: { color: "#22c55e", bg: "rgba(34, 197, 94, 0.1)", label: "Fast network", icon: <Wifi size={14} /> },
normal: { color: "var(--text-secondary)", bg: "transparent", label: "Normal network", icon: <Wifi size={14} /> },
slow: {
color: "#eab308",
bg: "rgba(234, 179, 8, 0.1)",
label: "Slow network — reduced refresh rate",
icon: <Clock size={14} />,
},
degraded: {
color: "#ef4444",
bg: "rgba(239, 68, 68, 0.1)",
label: "Degraded network — data may be stale",
icon: <AlertTriangle size={14} />,
},
};

const HighLatencyBanner: React.FC = () => {
const { quality, latencyMs, jitterMs } = useNetworkQuality();
const [dismissed, setDismissed] = useState(false);

if (quality === "fast" || quality === "normal" || dismissed) return null;

const cfg = CONFIG[quality];

return (
<div
role="alert"
style={{
display: "flex",
alignItems: "center",
gap: "10px",
padding: "8px 14px",
borderRadius: "8px",
background: cfg.bg,
border: `1px solid ${cfg.color}33`,
color: cfg.color,
fontSize: "0.8rem",
lineHeight: 1.4,
marginBottom: "12px",
animation: "fade-in 0.3s ease-out",
}}
>
{cfg.icon}
<span style={{ flex: 1 }}>
{cfg.label}
<span style={{ opacity: 0.7, fontSize: "0.72rem", marginLeft: 8 }}>
({latencyMs}ms{quality === "degraded" ? `, jitter ${jitterMs}ms` : ""})
</span>
</span>
<button
type="button"
onClick={() => setDismissed(true)}
aria-label="Dismiss"
style={{
all: "unset",
cursor: "pointer",
opacity: 0.6,
display: "flex",
padding: 2,
}}
>
<X size={14} />
</button>
</div>
);
};

export default HighLatencyBanner;
80 changes: 80 additions & 0 deletions frontend/src/components/Skeleton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -165,5 +165,85 @@ export const ChartSkeleton: React.FC = () => {
);
};

export const SharePriceSkeleton: React.FC = () => (
<div style={{ display: "flex", alignItems: "center", gap: "6px" }} aria-hidden="true">
<SkeletonCircle width={16} height={16} />
<SkeletonText width="80px" lineHeight="1rem" />
</div>
);

export const VaultStatSkeleton: React.FC = () => (
<div aria-hidden="true" style={{ display: "flex", flexDirection: "column", gap: "6px" }}>
<SkeletonText width="60%" lineHeight="0.75rem" />
<SkeletonBlock width="120px" height="1.5rem" borderRadius="var(--radius-sm)" />
</div>
);

export const TransactionRowSkeleton: React.FC = () => (
<div
aria-hidden="true"
className="data-table-row"
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
padding: "12px 0",
gap: "12px",
}}
>
<div style={{ flex: 2 }}>
<SkeletonText width="70%" lineHeight="1rem" />
<SkeletonText width="40%" lineHeight="0.75rem" style={{ marginTop: "4px" }} />
</div>
<div style={{ flex: 1, textAlign: "right" }}>
<SkeletonBlock width="80px" height="1rem" borderRadius="var(--radius-sm)" />
</div>
<div style={{ flex: 1, textAlign: "right" }}>
<SkeletonBlock width="60px" height="22px" borderRadius="99px" />
</div>
</div>
);

export const PortfolioCardSkeleton: React.FC = () => (
<div className="glass-panel" style={{ padding: "20px" }} aria-hidden="true">
<div style={{ display: "flex", justifyContent: "space-between", marginBottom: "16px" }}>
<div style={{ display: "flex", alignItems: "center", gap: "10px" }}>
<SkeletonCircle width={32} height={32} />
<div>
<SkeletonText width="100px" lineHeight="1rem" />
<SkeletonText width="60px" lineHeight="0.75rem" style={{ marginTop: "4px" }} />
</div>
</div>
<div style={{ textAlign: "right" }}>
<SkeletonText width="80px" lineHeight="1rem" />
<SkeletonText width="50px" lineHeight="0.75rem" style={{ marginTop: "4px" }} />
</div>
</div>
<div
style={{
height: "1px",
background: "var(--border-glass)",
marginBottom: "12px",
}}
/>
<div style={{ display: "flex", justifyContent: "space-between" }}>
<SkeletonText width="50%" lineHeight="0.8rem" />
<SkeletonText width="30%" lineHeight="0.8rem" />
</div>
</div>
);

export const AnalyticsWidgetSkeleton: React.FC = () => (
<div className="glass-panel" style={{ padding: "20px" }} aria-hidden="true">
<div style={{ display: "flex", alignItems: "center", gap: "8px", marginBottom: "16px" }}>
<SkeletonCircle width={18} height={18} />
<SkeletonText width="140px" lineHeight="1rem" />
</div>
<SkeletonBlock width="100%" height="48px" borderRadius="var(--radius-sm)" style={{ marginBottom: "12px" }} />
<SkeletonText width="80%" lineHeight="0.75rem" />
<SkeletonText width="50%" lineHeight="0.75rem" style={{ marginTop: "6px" }} />
</div>
);

// Default export for backward compatibility
export default SkeletonBlock;
8 changes: 2 additions & 6 deletions frontend/src/components/VaultDashboard.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import React, { useEffect, useRef, useState } from "react";
import confetti from "canvas-confetti";
import React, { useEffect, useState } from "react";
import {
Activity,
AlertCircle,
Expand Down Expand Up @@ -45,10 +44,7 @@ import { useOfflineRetryCountdown } from "../hooks/useOfflineRetryCountdown";
import { useFormFocusFlow } from "../hooks/useFormFocusFlow";
import { useStaleSubmissionGuard } from "../hooks/useStaleSubmissionGuard";
import { useTransactionIntent } from "../hooks/useTransactionIntent";
import {
clearVaultFormDraft,
saveVaultFormDraft,
} from "../lib/formDraftStorage";
import { saveVaultFormDraft } from "../lib/formDraftStorage";
import { buildDepositSummary, buildWithdrawalSummary } from "../lib/transactionConfirmationBuilder";
import TransactionConflictResolver from "./TransactionConflictResolver";
import {
Expand Down
4 changes: 2 additions & 2 deletions frontend/src/components/WalletConnect.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,7 @@ import {
isProviderAvailable,
} from "../lib/walletSession";
import WalletReconnectPrompt from "./WalletReconnectPrompt";
import { usePreferencesContext } from "../context/PreferencesContext";
import { displayIdentifier } from "../lib/maskSensitiveValues";
import WalletSessionIndicator from "./WalletSessionIndicator";

const IS_AUTOMATED_TEST =
typeof process !== "undefined" &&
Expand Down Expand Up @@ -239,6 +238,7 @@ const WalletConnect: React.FC<WalletConnectProps> = ({
if (walletAddress) {
return (
<div className="wallet-status flex items-center gap-md">
<WalletSessionIndicator walletAddress={walletAddress} />
<div
className="glass-panel"
style={{
Expand Down
156 changes: 156 additions & 0 deletions frontend/src/components/WalletSessionIndicator.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
import React, { useState, useRef, useCallback } from "react";
import { useWalletHeartbeat, type HeartbeatState } from "../hooks/useWalletHeartbeat";
import { RefreshCw, Wifi, WifiOff } from "./icons";

const CONFIG: Record<HeartbeatState, { dot: string; glow: string; labelKey: string }> = {
healthy: { dot: "#22c55e", glow: "#22c55e59", labelKey: "wallet.heartbeat.healthy" },
degraded: { dot: "#eab308", glow: "#eab30859", labelKey: "wallet.heartbeat.degraded" },
unhealthy: { dot: "#ef4444", glow: "#ef444459", labelKey: "wallet.heartbeat.unhealthy" },
};

function formatLatency(ms: number | null): string {
if (ms === null) return "—";
return ms < 1000 ? `${ms}ms` : `${(ms / 1000).toFixed(1)}s`;
}

interface WalletSessionIndicatorProps {
walletAddress: string | null;
}

const WalletSessionIndicator: React.FC<WalletSessionIndicatorProps> = ({ walletAddress }) => {
const { state, latencyMs, lastChecked, consecutiveFailures, refetch } = useWalletHeartbeat(walletAddress);
const [show, setShow] = useState(false);
const [pos, setPos] = useState<"top" | "bottom">("top");
const ref = useRef<HTMLDivElement>(null);
const { dot, glow } = CONFIG[state];

const onEnter = useCallback(() => {
if (ref.current) {
setPos(ref.current.getBoundingClientRect().top < window.innerHeight / 3 ? "bottom" : "top");
}
setShow(true);
}, []);

if (!walletAddress) return null;

return (
<>
<style>{`
@keyframes hb-pulse { 0%,100%{opacity:1;transform:scale(1)} 50%{opacity:.6;transform:scale(.85)} }
`}</style>

<div
ref={ref}
style={{ position: "relative", display: "inline-flex", alignItems: "center" }}
onMouseEnter={onEnter}
onMouseLeave={() => setShow(false)}
onFocus={onEnter}
onBlur={() => setShow(false)}
>
<button
type="button"
onClick={() => void refetch()}
aria-label={`Wallet session: ${state}. Latency: ${formatLatency(latencyMs)}`}
aria-describedby={show ? "wsi-tooltip" : undefined}
style={{
all: "unset",
cursor: "pointer",
width: 10, height: 10,
borderRadius: "50%",
background: dot,
boxShadow: `0 0 0 3px ${glow}`,
outline: "revert",
outlineOffset: 3,
animation: state === "unhealthy" ? "hb-pulse 1.8s ease-in-out infinite"
: state === "degraded" ? "pulseSoft 2.5s ease-in-out infinite"
: "none",
}}
/>

{show && (
<div
id="wsi-tooltip"
role="tooltip"
style={{
position: "absolute",
...(pos === "top" ? { bottom: "calc(100% + 10px)" } : { top: "calc(100% + 10px)" }),
left: "50%", transform: "translateX(-50%)",
minWidth: 200, maxWidth: 260,
background: "var(--bg-surface, #1a1a2e)",
border: `1px solid ${dot}33`,
borderRadius: 8,
padding: "10px 14px",
fontSize: "0.75rem",
color: "var(--text-secondary, #94a3b8)",
zIndex: 1000,
pointerEvents: "none",
whiteSpace: "nowrap",
}}
>
<div style={{ fontWeight: 600, color: dot, marginBottom: 6, fontSize: "0.8rem" }}>
{state === "healthy" ? <Wifi size={12} style={{ display: "inline", marginRight: 4 }} /> :
state === "unhealthy" ? <WifiOff size={12} style={{ display: "inline", marginRight: 4 }} /> :
<RefreshCw size={12} style={{ display: "inline", marginRight: 4 }} />}
Wallet {state.charAt(0).toUpperCase() + state.slice(1)}
</div>

<div style={{ lineHeight: 1.4, marginBottom: 4 }}>
Latency: {formatLatency(latencyMs)}
</div>

{consecutiveFailures > 0 && (
<div style={{ color: "#ef4444", marginBottom: 4 }}>
{consecutiveFailures} failed check{consecutiveFailures !== 1 ? "s" : ""}
</div>
)}

{state === "unhealthy" && (
<div
style={{
marginTop: 8,
padding: "6px 8px",
borderRadius: 4,
background: "rgba(239, 68, 68, 0.1)",
color: "#ef4444",
fontSize: "0.7rem",
lineHeight: 1.3,
}}
>
Session may be lost. Open Freighter to reconnect.
</div>
)}

{state === "degraded" && (
<div
style={{
marginTop: 8,
padding: "6px 8px",
borderRadius: 4,
background: "rgba(234, 179, 8, 0.1)",
color: "#eab308",
fontSize: "0.7rem",
lineHeight: 1.3,
}}
>
Wallet responses are slow. Check Freighter connection.
</div>
)}

<div style={{ borderTop: "1px solid rgba(255,255,255,0.08)", paddingTop: 6, fontSize: "0.68rem", opacity: 0.5, marginTop: 6 }}>
Last checked {lastChecked ? relativeTime(lastChecked) : "never"}
</div>
</div>
)}
</div>
</>
);
};

function relativeTime(date: Date): string {
const s = Math.floor((Date.now() - date.getTime()) / 1000);
if (s < 5) return "just now";
if (s < 60) return `${s}s ago`;
return `${Math.floor(s / 60)}m ago`;
}

export default WalletSessionIndicator;
3 changes: 3 additions & 0 deletions frontend/src/components/icons.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,14 @@ export {
MessageCircle,
Moon,
Printer,
RefreshCw,
ShieldCheck,
Sun,
TrendingUp,
Twitter,
Wallet,
Wifi,
WifiOff,
X,
DollarSign,
Percent,
Expand Down
Loading
Loading