Skip to content

Commit 21cc6d2

Browse files
committed
feat: implement wallet address toggle, multi-account detection and a11y focus/announcements
1 parent a81de60 commit 21cc6d2

6 files changed

Lines changed: 151 additions & 23 deletions

File tree

app/globals.css

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,11 @@
3636
background-color: rgba(92, 124, 250, 0.3);
3737
color: #ffffff;
3838
}
39+
40+
:focus-visible {
41+
outline: 2px solid #5c7cfa !important;
42+
outline-offset: 3px !important;
43+
}
3944
}
4045

4146
@layer components {

app/layout.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import type { Metadata } from "next";
22
import { Inter, JetBrains_Mono, Space_Grotesk } from "next/font/google";
33
import "@/lib/env";
44
import { AppShell } from "@/components/ui/app-shell";
5-
import { StellarAuthProvider } from "@/contexts/StellarAuthContext";
5+
import { StellarProvider } from "@/context/StellarContext";
66
import "./globals.css";
77

88
const inter = Inter({
@@ -55,9 +55,9 @@ export default function RootLayout({
5555
<body
5656
className={`${inter.variable} ${spaceGrotesk.variable} ${jetBrainsMono.variable} font-sans`}
5757
>
58-
<StellarAuthProvider>
58+
<StellarProvider>
5959
<AppShell>{children}</AppShell>
60-
</StellarAuthProvider>
60+
</StellarProvider>
6161
</body>
6262
</html>
6363
);
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
"use client";
2+
3+
import { useStellar } from "@/context/StellarContext";
4+
import { AlertCircle, RefreshCw, X } from "lucide-react";
5+
6+
export default function AccountChangedBanner() {
7+
const { hasAccountChanged, setHasAccountChanged, connect } = useStellar();
8+
9+
if (!hasAccountChanged) return null;
10+
11+
return (
12+
<div className="fixed top-0 left-0 right-0 z-[100] animate-in slide-in-from-top duration-500">
13+
<div className="bg-brand-600 px-4 py-3 shadow-2xl backdrop-blur-md border-b border-brand-400/30">
14+
<div className="max-w-7xl mx-auto flex items-center justify-between gap-4">
15+
<div className="flex items-center gap-3">
16+
<div className="bg-white/10 p-2 rounded-lg">
17+
<AlertCircle size={20} className="text-white animate-pulse" />
18+
</div>
19+
<p className="text-sm md:text-base font-bold text-white tracking-tight">
20+
Your Freighter account changed. <span className="text-brand-200">Reconnect to continue.</span>
21+
</p>
22+
</div>
23+
24+
<div className="flex items-center gap-2">
25+
<button
26+
onClick={() => connect()}
27+
className="flex items-center gap-2 bg-white text-brand-600 px-4 py-2 rounded-lg text-sm font-black uppercase tracking-widest hover:bg-brand-50 transition-all active:scale-95 shadow-lg"
28+
>
29+
<RefreshCw size={16} />
30+
Reconnect
31+
</button>
32+
<button
33+
onClick={() => setHasAccountChanged(false)}
34+
className="p-2 text-white/60 hover:text-white transition-colors"
35+
aria-label="Dismiss banner"
36+
>
37+
<X size={20} />
38+
</button>
39+
</div>
40+
</div>
41+
</div>
42+
</div>
43+
);
44+
}

components/ui/app-shell.tsx

Lines changed: 28 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,46 @@
11
"use client";
22

3-
import React from "react";
3+
import React, { useState, useEffect } from "react";
44
import { ThemeProvider } from "@/components/ui/theme-provider";
55
import { DottedSurface } from "@/components/ui/dotted-surface";
6-
import { StellarProvider } from "@/context/StellarContext";
6+
import { useStellar } from "@/context/StellarContext";
77
import { ToastProvider } from "@/components/ui/toast-provider";
88
import { SkipLink } from "@/components/ui/skip-link";
99
import { ErrorBoundary } from "@/components/ui/error-boundary";
1010
import OfflineBanner from "./offline-banner";
11+
import AccountChangedBanner from "./account-changed-banner";
1112

1213
/** True when the device reports fewer than 4 logical CPU cores. */
1314
const isLowEnd =
1415
typeof navigator !== "undefined" &&
1516
typeof navigator.hardwareConcurrency === "number" &&
1617
navigator.hardwareConcurrency < 4;
1718

18-
export function AppShell({ children }: { children: React.ReactNode }) {
19+
const { announcement } = useStellar();
20+
const [liveMessage, setLiveMessage] = useState<string | null>(null);
21+
22+
useEffect(() => {
23+
if (announcement) {
24+
setLiveMessage(announcement);
25+
}
26+
}, [announcement]);
27+
1928
return (
2029
<ThemeProvider
2130
attribute="class"
2231
defaultTheme="dark"
2332
enableSystem={false}
2433
disableTransitionOnChange
2534
>
35+
{/* Screen Reader Announcements */}
36+
<div
37+
aria-live="polite"
38+
className="sr-only"
39+
role="status"
40+
>
41+
{liveMessage}
42+
</div>
43+
2644
{/* #548: skip Three.js canvas on low-end devices; use a lightweight CSS gradient instead */}
2745
{isLowEnd ? (
2846
<div
@@ -38,14 +56,13 @@ export function AppShell({ children }: { children: React.ReactNode }) {
3856
)}
3957
<div className="mesh-gradient" aria-hidden="true" />
4058
<SkipLink />
41-
<StellarProvider>
42-
<ToastProvider>
43-
<ErrorBoundary>
44-
<OfflineBanner />
45-
<div className="relative z-10">{children}</div>
46-
</ErrorBoundary>
47-
</ToastProvider>
48-
</StellarProvider>
59+
<ToastProvider>
60+
<ErrorBoundary>
61+
<OfflineBanner />
62+
<AccountChangedBanner />
63+
<div className="relative z-10">{children}</div>
64+
</ErrorBoundary>
65+
</ToastProvider>
4966
</ThemeProvider>
5067
);
5168
}

components/wallet/ConnectWalletButton.tsx

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import React, { useState, useRef, useEffect } from "react";
44
import { useRouter } from "next/navigation";
55
import { motion, AnimatePresence } from "framer-motion";
6-
import { Wallet, LogOut, Copy, Check, ChevronDown, ExternalLink, AlertCircle, Coins, AlertTriangle } from "lucide-react";
6+
import { Wallet, LogOut, Copy, Check, ChevronDown, ExternalLink, AlertCircle, Coins, AlertTriangle, Maximize2, Minimize2 } from "lucide-react";
77
import { useStellarAuth } from "@/context/StellarContext";
88
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
99
import { BottomSheet } from "@/components/ui/bottom-sheet";
@@ -38,9 +38,17 @@ export default function ConnectWalletButton() {
3838
const dropdownRef = useRef<HTMLDivElement>(null);
3939
const router = useRouter();
4040
const [showConfirm, setShowConfirm] = useState(false);
41+
const [showFullAddress, setShowFullAddress] = useState(false);
4142
const isMobile = useIsMobile();
4243
const appNetwork = getCurrentNetwork();
4344

45+
// Auto-collapse full address when dropdown closes
46+
useEffect(() => {
47+
if (!isOpen) {
48+
setShowFullAddress(false);
49+
}
50+
}, [isOpen]);
51+
4452
const hasNetworkMismatch = isWalletNetworkMismatch(walletNetwork, appNetwork);
4553
const mismatchMessage = hasNetworkMismatch
4654
? `Wallet is on ${toDisplayNetworkName(walletNetwork!)}, app uses ${toDisplayNetworkName(appNetwork === "mainnet" ? "MAINNET" : "TESTNET")}.`
@@ -246,11 +254,20 @@ export default function ConnectWalletButton() {
246254
animate={{ opacity: 1, y: 5, scale: 1 }}
247255
exit={{ opacity: 0, y: 10, scale: 0.95 }}
248256
transition={{ duration: 0.2, ease: "easeOut" }}
249-
className="absolute right-0 top-full z-50 w-48 mt-2 glass rounded-xl border border-white/10 shadow-2xl overflow-hidden"
257+
className="absolute right-0 top-full z-50 w-64 mt-2 glass rounded-xl border border-white/10 shadow-2xl overflow-hidden"
250258
>
251259
{/* Balance display */}
252260
<div className="px-3 py-2 border-b border-white/5 mb-1">
253-
<p className="text-[10px] text-dark-500 uppercase tracking-widest font-semibold mb-0.5">Balance</p>
261+
<div className="flex items-center justify-between mb-0.5">
262+
<p className="text-[10px] text-dark-500 uppercase tracking-widest font-semibold">Balance</p>
263+
<button
264+
onClick={() => setShowFullAddress(!showFullAddress)}
265+
className="p-1 rounded-md bg-white/5 hover:bg-brand-500/20 text-brand-400 transition-all"
266+
title={showFullAddress ? "Show Truncated" : "Show Full Address"}
267+
>
268+
{showFullAddress ? <Minimize2 size={12} /> : <Maximize2 size={12} />}
269+
</button>
270+
</div>
254271
{isBalanceLoading ? (
255272
<div className="h-4 w-20 bg-dark-800/60 rounded animate-pulse" />
256273
) : (
@@ -261,6 +278,12 @@ export default function ConnectWalletButton() {
261278
)}
262279
</div>
263280

281+
<div className="px-3 py-2 bg-dark-950/30 rounded-lg mx-1 mb-2 border border-white/5">
282+
<p className={`text-[11px] font-mono break-all leading-relaxed ${showFullAddress ? "text-dark-100" : "text-dark-400"}`}>
283+
{showFullAddress ? publicKey : truncatedKey}
284+
</p>
285+
</div>
286+
264287
<div className="p-1">
265288
<div className="flex items-center justify-between px-3 py-2 hover:bg-white/5 rounded-lg transition-colors">
266289
<div className="flex items-center gap-3 text-sm text-dark-300">

context/StellarContext.tsx

Lines changed: 45 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,10 @@ interface StellarContextType {
1818
connect: () => Promise<void>;
1919
disconnect: () => void;
2020
error: WalletError | null;
21+
hasAccountChanged: boolean;
22+
setHasAccountChanged: (val: boolean) => void;
23+
announcement: string | null;
24+
announce: (message: string) => void;
2125
}
2226

2327
const StellarContext = createContext<StellarContextType | undefined>(undefined);
@@ -29,6 +33,13 @@ export function StellarProvider({ children }: { children: React.ReactNode }) {
2933
const [isConnecting, setIsConnecting] = useState(false);
3034
const [isRestoring, setIsRestoring] = useState(true);
3135
const [error, setError] = useState<WalletError | null>(null);
36+
const [hasAccountChanged, setHasAccountChanged] = useState(false);
37+
const [announcement, setAnnouncement] = useState<string | null>(null);
38+
39+
const announce = useCallback((message: string) => {
40+
setAnnouncement(message);
41+
setTimeout(() => setAnnouncement(null), 2000);
42+
}, []);
3243

3344
// Initialize connection state
3445
useEffect(() => {
@@ -39,7 +50,6 @@ export function StellarProvider({ children }: { children: React.ReactNode }) {
3950
setIsFreighterInstalled(installed);
4051

4152
if (installed) {
42-
// Check if the site is allowed and the user is connected
4353
const connected = await checkConnection();
4454
if (connected) {
4555
const key = await fetchPublicKey();
@@ -58,9 +68,31 @@ export function StellarProvider({ children }: { children: React.ReactNode }) {
5868
init();
5969
}, []);
6070

71+
// Multi-account change detection polling
72+
useEffect(() => {
73+
if (!isConnected || !publicKey) {
74+
setHasAccountChanged(false);
75+
return;
76+
}
77+
78+
const interval = setInterval(async () => {
79+
try {
80+
const currentKey = await fetchPublicKey();
81+
if (currentKey && currentKey !== publicKey) {
82+
setHasAccountChanged(true);
83+
}
84+
} catch (err) {
85+
console.error("Polling error:", err);
86+
}
87+
}, 30000);
88+
89+
return () => clearInterval(interval);
90+
}, [isConnected, publicKey]);
91+
6192
const connect = useCallback(async () => {
6293
setIsConnecting(true);
6394
setError(null);
95+
setHasAccountChanged(false);
6496
try {
6597
const installed = await checkFreighter();
6698
if (!installed) {
@@ -71,24 +103,27 @@ export function StellarProvider({ children }: { children: React.ReactNode }) {
71103
if (key) {
72104
setPublicKey(key);
73105
setIsConnected(true);
106+
announce("Wallet connected successfully.");
74107
} else {
75108
throw new Error("User rejected connection or failed to retrieve public key.");
76109
}
77110
} catch (err) {
78-
setError(getWalletError(err));
111+
const walletErr = getWalletError(err);
112+
setError(walletErr);
79113
setIsConnected(false);
80114
setPublicKey(null);
115+
announce(`Error: ${walletErr.message}`);
81116
} finally {
82117
setIsConnecting(false);
83118
}
84-
}, []);
119+
}, [announce]);
85120

86121
const disconnect = useCallback(() => {
87122
setPublicKey(null);
88123
setIsConnected(false);
89-
// Note: Freighter doesn't have a formal 'disconnect' API that clears permissions,
90-
// so we just clear our local state.
91-
}, []);
124+
setHasAccountChanged(false);
125+
announce("Wallet disconnected.");
126+
}, [announce]);
92127

93128
return (
94129
<StellarContext.Provider
@@ -101,6 +136,10 @@ export function StellarProvider({ children }: { children: React.ReactNode }) {
101136
connect,
102137
disconnect,
103138
error,
139+
hasAccountChanged,
140+
setHasAccountChanged,
141+
announcement,
142+
announce,
104143
}}
105144
>
106145
{children}

0 commit comments

Comments
 (0)