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
76 changes: 76 additions & 0 deletions components/atoms/wallet-button/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { useState, useRef, useEffect } from 'react';
import { setAllowed } from '@stellar/freighter-api';

interface WalletButtonProps {
address: string;
onDisconnect: () => void;
}

/**
* Shows the connected wallet address and a dropdown with options to switch
* accounts or disconnect. Clicking outside closes the menu.
*/
export function WalletButton({ address, onDisconnect }: WalletButtonProps) {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);

useEffect(() => {
function handleClickOutside(e: MouseEvent) {
if (ref.current && !ref.current.contains(e.target as Node)) {
setOpen(false);
}
}
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);

const displayName = `${address.slice(0, 4)}...${address.slice(-4)}`;

function handleSwap() {
setOpen(false);
// Re-invoking setAllowed opens the Freighter permission popup so the user
// can approve a different profile without disconnecting first.
void setAllowed();
}

function handleDisconnect() {
setOpen(false);
onDisconnect();
}

return (
<div ref={ref} className="relative">
<button
onClick={() => setOpen((v) => !v)}
aria-expanded={open}
aria-haspopup="true"
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-medium bg-gray-100 dark:bg-gray-800 text-gray-900 dark:text-white hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors"
>
<span className="w-2 h-2 rounded-full bg-green-500" aria-hidden="true" />
{displayName}
</button>

{open && (
<div
role="menu"
className="absolute right-0 mt-1 w-44 rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 shadow-lg py-1 z-50"
>
<button
role="menuitem"
onClick={handleSwap}
className="w-full text-left px-3 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors"
>
Switch account
</button>
<button
role="menuitem"
onClick={handleDisconnect}
className="w-full text-left px-3 py-2 text-sm text-red-600 dark:text-red-400 hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors"
>
Disconnect
</button>
</div>
)}
</div>
);
}
35 changes: 21 additions & 14 deletions components/organisms/navbar/index.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
import { useState } from 'react';
import { useState, useCallback } from 'react';
import Link from 'next/link';
import { useRouter } from 'next/router';
import { useTranslations } from 'next-intl';
import { ThemeToggle } from '../../atoms/theme-toggle';
import { LocaleSwitcher } from '../../atoms/locale-switcher';
import { ConnectButton } from '../../atoms/connect-button';
import { WalletButton } from '../../atoms/wallet-button';
import { useAccount } from '../../../hooks/useAccount';

const NAV_LINKS = [
{ key: 'home', href: '/' },
Expand All @@ -17,6 +20,13 @@ export function Navbar() {
const [open, setOpen] = useState(false);
const router = useRouter();
const t = useTranslations('Nav');
const account = useAccount();

// useAccount state is driven by Freighter events; clearing it locally is
// enough to reflect a disconnect since there is no server-side session.
const [disconnected, setDisconnected] = useState(false);
const handleDisconnect = useCallback(() => setDisconnected(true), []);
const effectiveAccount = disconnected ? null : account;

const toggle = () => setOpen((v) => !v);
const close = () => setOpen(false);
Expand Down Expand Up @@ -50,12 +60,11 @@ export function Navbar() {
<div className="hidden md:flex items-center gap-3">
<LocaleSwitcher />
<ThemeToggle />
<Link
href="/escrow"
className="px-4 py-2 bg-indigo-600 hover:bg-indigo-700 text-white text-sm font-semibold rounded-lg transition-all hover:-translate-y-px"
>
{t('launchApp')}
</Link>
{effectiveAccount ? (
<WalletButton address={effectiveAccount.address} onDisconnect={handleDisconnect} />
) : (
<ConnectButton label={t('connectWallet')} />
)}
</div>

{/* Hamburger */}
Expand Down Expand Up @@ -93,13 +102,11 @@ export function Navbar() {
<div className="mt-3 flex items-center gap-3 pt-3 border-t border-gray-100 dark:border-gray-800">
<LocaleSwitcher />
<ThemeToggle />
<Link
href="/escrow"
onClick={close}
className="flex-1 text-center px-4 py-2 bg-indigo-600 hover:bg-indigo-700 text-white text-sm font-semibold rounded-lg transition-colors"
>
{t('launchApp')}
</Link>
{effectiveAccount ? (
<WalletButton address={effectiveAccount.address} onDisconnect={handleDisconnect} />
) : (
<ConnectButton label={t('connectWallet')} />
)}
</div>
</div>
)}
Expand Down
78 changes: 33 additions & 45 deletions hooks/useAccount.ts
Original file line number Diff line number Diff line change
@@ -1,49 +1,37 @@
import { useEffect, useState } from "react";
import { useEffect, useState, useCallback } from "react";
import { isConnected, getUserInfo } from "@stellar/freighter-api";

let address: string;

let addressLookup = (async () => {
if (await isConnected()) return getUserInfo()
})();

// returning the same object identity every time avoids unnecessary re-renders
const addressObject = {
address: '',
displayName: '',
};

const addressToHistoricObject = (address: string) => {
addressObject.address = address;
addressObject.displayName = `${address.slice(0, 4)}...${address.slice(-4)}`;
return addressObject
};

/**
* Returns an object containing `address` and `displayName` properties, with
* the address fetched from Freighter's `getPublicKey` method in a
* render-friendly way.
*
* Before the address is fetched, returns null.
*
* Caches the result so that the Freighter lookup only happens once, no matter
* how many times this hook is called.
*
* NOTE: This does not update the return value if the user changes their
* Freighter settings; they will need to refresh the page.
*/
export function useAccount(): typeof addressObject | null {
const [, setLoading] = useState(address === undefined);

useEffect(() => {
if (address !== undefined) return;

addressLookup
.then(user => { if (user) address = user.publicKey })
.finally(() => { setLoading(false) });
export interface AccountInfo {
address: string;
displayName: string;
}

export function useAccount(): AccountInfo | null {
const [address, setAddress] = useState<string | null>(null);

const syncAccount = useCallback(async () => {
try {
const connected = await isConnected();
if (!connected) {
setAddress(null);
return;
}
const user = await getUserInfo();
setAddress(user?.publicKey ?? null);
} catch {
setAddress(null);
}
}, []);

if (address) return addressToHistoricObject(address);

return null;
};
useEffect(() => {
syncAccount();
const interval = setInterval(syncAccount, 2000);
return () => clearInterval(interval);
}, [syncAccount]);

if (!address) return null;
return {
address,
displayName: `${address.slice(0, 4)}...${address.slice(-4)}`,
};
}
Loading