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
72 changes: 67 additions & 5 deletions src/components/CrossChainPayment.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,11 +53,38 @@ function ChainSelector({
function FeeBreakdown({
amount,
estimate,
loading,
error,
}: {
amount: string;
estimate: FeeEstimate | null;
loading: boolean;
error: boolean;
}) {
if (!amount || parseFloat(amount) <= 0 || !estimate) return null;
if (!amount || parseFloat(amount) <= 0) return null;

if (loading) {
return (
<div
role="status"
aria-label="Loading bridge estimate"
className="bg-gray-800 rounded-lg p-4 flex flex-col gap-2 animate-pulse"
>
<div className="h-3 w-2/3 bg-gray-700 rounded" />
<div className="h-3 w-1/2 bg-gray-700 rounded" />
<div className="h-3 w-3/4 bg-gray-700 rounded" />
<div className="h-3 w-1/3 bg-gray-700 rounded" />
</div>
);
}

if (error || !estimate) {
return (
<div className="bg-gray-800 rounded-lg p-4 text-sm text-gray-400">
Estimate unavailable
</div>
);
}

return (
<dl className="bg-gray-800 rounded-lg p-4 text-sm flex flex-col gap-2">
Expand Down Expand Up @@ -243,6 +270,8 @@ export default function CrossChainPayment({ invoiceId, stellarDestination }: Pro
const [chain, setChain] = useState<SupportedChain>("ethereum");
const [amount, setAmount] = useState("");
const [estimate, setEstimate] = useState<FeeEstimate | null>(null);
const [estimateLoading, setEstimateLoading] = useState(false);
const [estimateError, setEstimateError] = useState(false);

// Wallet
const [walletAddress, setWalletAddress] = useState<string | null>(null);
Expand All @@ -260,11 +289,39 @@ export default function CrossChainPayment({ invoiceId, stellarDestination }: Pro

// Recalculate fee estimate whenever chain or amount changes
useEffect(() => {
if (amount && parseFloat(amount) > 0) {
setEstimate(estimateBridgeFee(chain, amount));
} else {
if (!amount || parseFloat(amount) <= 0) {
setEstimate(null);
setEstimateLoading(false);
setEstimateError(false);
return;
}

let cancelled = false;
setEstimateLoading(true);
setEstimateError(false);

// Debounce briefly and simulate fetching a fresh estimate for the
// selected chain/amount.
const timer = setTimeout(() => {
try {
const result = estimateBridgeFee(chain, amount);
if (!cancelled) {
setEstimate(result);
setEstimateLoading(false);
}
} catch {
if (!cancelled) {
setEstimate(null);
setEstimateError(true);
setEstimateLoading(false);
}
}
}, 300);

return () => {
cancelled = true;
clearTimeout(timer);
};
}, [chain, amount]);

// Reset wallet when chain changes
Expand Down Expand Up @@ -408,7 +465,12 @@ export default function CrossChainPayment({ invoiceId, stellarDestination }: Pro
</div>

{/* Fee estimate */}
<FeeBreakdown amount={amount} estimate={estimate} />
<FeeBreakdown
amount={amount}
estimate={estimate}
loading={estimateLoading}
error={estimateError}
/>
</>
)}

Expand Down
38 changes: 36 additions & 2 deletions src/components/NetworkStatus.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -63,11 +63,19 @@ export default function NetworkStatus() {
const [checkedAt, setCheckedAt] = useState<Date | null>(null);
const [dismissed, setDismissed] = useState(false);
const [secondsAgo, setSecondsAgo] = useState(0);
const [justReconnected, setJustReconnected] = useState(false);
const [browserOffline, setBrowserOffline] = useState(false);
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);

const poll = useCallback(async () => {
const s = await checkHealth();
setStatus(s);
setStatus((prev) => {
if (prev === "offline" && s !== "offline") {
setJustReconnected(true);
setTimeout(() => setJustReconnected(false), 4000);
}
return s;
});
setCheckedAt(new Date());
setSecondsAgo(0);
if (s !== "offline") setDismissed(false);
Expand All @@ -88,12 +96,26 @@ export default function NetworkStatus() {
const onVisibility = () => {
document.hidden ? stop() : (poll(), start());
};
const onOnline = () => {
setBrowserOffline(false);
poll();
};
const onOffline = () => {
setBrowserOffline(true);
setStatus("offline");
setCheckedAt(new Date());
setDismissed(false);
};

start();
document.addEventListener("visibilitychange", onVisibility);
window.addEventListener("online", onOnline);
window.addEventListener("offline", onOffline);
return () => {
stop();
document.removeEventListener("visibilitychange", onVisibility);
window.removeEventListener("online", onOnline);
window.removeEventListener("offline", onOffline);
};
}, [poll]);

Expand Down Expand Up @@ -159,7 +181,9 @@ export default function NetworkStatus() {
className="fixed top-0 left-0 right-0 z-[9999] flex items-center justify-between gap-3 bg-red-900/95 border-b border-red-700 px-4 py-2 text-sm text-red-100"
>
<span>
Network issues detected — transactions may fail
{browserOffline
? "You are offline — transactions may fail"
: "Network issues detected — transactions may fail"}
{NETWORK !== "Unknown" && (
<span className="ml-1 opacity-75">({NETWORK})</span>
)}
Expand All @@ -175,6 +199,16 @@ export default function NetworkStatus() {
</div>
)}

{/* ── "Back online" banner ── */}
{status !== "offline" && justReconnected && (
<div
role="status"
className="fixed top-0 left-0 right-0 z-[9999] flex items-center justify-center gap-2 bg-green-900/95 border-b border-green-700 px-4 py-2 text-sm text-green-100"
>
Back online
</div>
)}

{/* ── Network badge + RPC health ── */}
<div className="flex items-center gap-2">
{/* Permanent Mainnet / Testnet badge */}
Expand Down
36 changes: 27 additions & 9 deletions src/components/ReputationBadge.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,16 +37,34 @@ export default function ReputationBadge({ address }: Props) {

const isVerified = reputation !== null && reputation > 0;

const tooltipText =
"Reputation is calculated from on-time payments, dispute win rate, " +
"and total transaction volume. Consistent, timely activity raises your score.";

return (
<span
className={`inline-flex items-center gap-1 text-xs px-1.5 py-0.5 rounded-full ${
isVerified
? "bg-green-900 text-green-300"
: "bg-gray-700 text-gray-400"
}`}
title={`Reputation: ${reputation ?? 0}`}
>
{isVerified ? "✓" : "○"}
<span className="group relative inline-flex">
<span
tabIndex={0}
role="img"
aria-label={`Reputation: ${reputation ?? 0}. ${tooltipText}`}
className={`inline-flex items-center gap-1 text-xs px-1.5 py-0.5 rounded-full cursor-default focus:outline-none focus:ring-2 focus:ring-indigo-500 ${
isVerified
? "bg-green-900 text-green-300"
: "bg-gray-700 text-gray-400"
}`}
>
{isVerified ? "✓" : "○"}
</span>
{/* Tooltip: shown on hover and keyboard focus */}
<span
role="tooltip"
className="pointer-events-none absolute bottom-full left-1/2 -translate-x-1/2 mb-2 z-50 hidden w-56 rounded bg-gray-900 border border-gray-700 px-2 py-1.5 text-xs text-gray-200 shadow-lg group-hover:block group-focus-within:block"
>
<span className="block font-semibold mb-0.5">
Reputation: {reputation ?? 0}
</span>
{tooltipText}
</span>
</span>
);
}
84 changes: 72 additions & 12 deletions src/components/SimulationModeToggle.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { getSimulationMode, setSimulationMode } from "@/lib/simulationMode";
export default function SimulationModeToggle() {
const [enabled, setEnabled] = useState(false);
const [mounted, setMounted] = useState(false);
const [confirming, setConfirming] = useState(false);

useEffect(() => {
setEnabled(getSimulationMode());
Expand All @@ -22,18 +23,77 @@ export default function SimulationModeToggle() {

if (!mounted) return null;

const handleToggleClick = () => {
if (enabled) {
// Turning simulation mode OFF (switching to live mode) — require confirmation.
setConfirming(true);
} else {
// Turning simulation mode ON — safe, no confirmation needed.
setSimulationMode(true);
}
};

const handleConfirmLiveMode = () => {
setSimulationMode(false);
setConfirming(false);
};

const handleCancel = () => {
setConfirming(false);
};

return (
<button
type="button"
onClick={() => setSimulationMode(!enabled)}
className={`text-xs px-2 py-1 rounded-full font-semibold transition-colors ${
enabled
? "bg-yellow-900 text-yellow-300 hover:bg-yellow-800"
: "bg-gray-700 text-gray-300 hover:bg-gray-600"
}`}
aria-label={`Simulation mode ${enabled ? "enabled" : "disabled"}`}
>
{enabled ? "🎮 SIM" : "SIM"}
</button>
<>
<button
type="button"
onClick={handleToggleClick}
className={`text-xs px-2 py-1 rounded-full font-semibold transition-colors ${
enabled
? "bg-yellow-900 text-yellow-300 hover:bg-yellow-800"
: "bg-gray-700 text-gray-300 hover:bg-gray-600"
}`}
aria-label={`Simulation mode ${enabled ? "enabled" : "disabled"}`}
>
{enabled ? "🎮 SIM" : "SIM"}
</button>

{confirming && (
<div
role="dialog"
aria-modal="true"
aria-labelledby="live-mode-confirm-title"
className="fixed inset-0 z-[9999] flex items-center justify-center bg-black/60 px-4"
>
<div className="w-full max-w-sm rounded-lg bg-gray-900 border border-gray-700 p-5 flex flex-col gap-4">
<h2
id="live-mode-confirm-title"
className="text-sm font-semibold text-white"
>
Switch to live mode?
</h2>
<p className="text-sm text-gray-300">
You are switching to live mode — all actions will use real
funds.
</p>
<div className="flex justify-end gap-2">
<button
type="button"
onClick={handleCancel}
className="px-3 py-1.5 rounded-md text-sm font-semibold bg-gray-700 text-gray-200 hover:bg-gray-600 transition-colors"
>
Cancel
</button>
<button
type="button"
onClick={handleConfirmLiveMode}
className="px-3 py-1.5 rounded-md text-sm font-semibold bg-red-700 text-white hover:bg-red-600 transition-colors"
>
Yes, switch to live mode
</button>
</div>
</div>
</div>
)}
</>
);
}