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
57 changes: 48 additions & 9 deletions components/atoms/connect-button/index.tsx
Original file line number Diff line number Diff line change
@@ -1,20 +1,59 @@
import React from 'react'
import { useState } from 'react'
import { setAllowed } from '@stellar/freighter-api'
import styles from './style.module.css'

export interface ConnectButtonProps {
label: string
isHigher?: boolean
/** Called after a successful setAllowed + wallet connection is detected */
onConnect?: () => void
}

export function ConnectButton({ label, isHigher }: ConnectButtonProps) {
/**
* Renders a "Connect Wallet" button that triggers the Freighter permission flow.
*
* - Shows a loading spinner while the connection is in progress
* - Displays inline error text if the connection fails
* - Fires `onConnect` so parents can refresh state after a successful connect
*/
export function ConnectButton({ label, isHigher, onConnect }: ConnectButtonProps) {
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)

async function handleClick() {
setLoading(true)
setError(null)
try {
await setAllowed()
onConnect?.()
} catch (err) {
const msg =
err instanceof Error ? err.message : 'Failed to connect wallet'
setError(msg)
} finally {
setLoading(false)
}
}

return (
<button
className={styles.button}
style={{ height: isHigher ? 50 : 38 }}
onClick={setAllowed}
>
{label}
</button>
<div className={styles.wrapper}>
<button
className={`${styles.button} ${loading ? styles.loading : ''}`}
style={{ height: isHigher ? 50 : 38, minWidth: isHigher ? 240 : undefined }}
onClick={handleClick}
disabled={loading}
aria-busy={loading}
>
{loading ? (
<span className={styles.spinner} aria-hidden="true" />
) : null}
<span>{loading ? 'Connecting…' : label}</span>
</button>
{error && (
<p className={styles.error} role="alert">
{error}
</p>
)}
</div>
)
}
60 changes: 57 additions & 3 deletions components/atoms/connect-button/style.module.css
Original file line number Diff line number Diff line change
@@ -1,3 +1,10 @@
.wrapper {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
}

.button {
display: flex;
flex-direction: row;
Expand All @@ -14,8 +21,55 @@
line-height: 22px;
color: #ffffff;
cursor: pointer;
transition: background 0.15s, opacity 0.15s, transform 0.12s;
}

.button:hover:not(:disabled) {
background: #2d2440;
transform: translateY(-1px);
}

.button:active:not(:disabled) {
transform: translateY(0);
}

.button:disabled {
opacity: 0.7;
cursor: not-allowed;
}

.higher {
height: 58px;
}
.button.loading {
opacity: 0.85;
}



/* ── Spinner ────────────────────────────────────── */

.spinner {
display: inline-block;
width: 16px;
height: 16px;
border: 2px solid rgba(255, 255, 255, 0.3);
border-top-color: #ffffff;
border-radius: 50%;
animation: spin 0.6s linear infinite;
flex-shrink: 0;
}

@keyframes spin {
to {
transform: rotate(360deg);
}
}

/* ── Error ──────────────────────────────────────── */

.error {
margin: 0;
font-size: 12px;
line-height: 1.4;
color: #ef4444;
text-align: center;
max-width: 240px;
}
115 changes: 92 additions & 23 deletions components/atoms/wallet-button/index.tsx
Original file line number Diff line number Diff line change
@@ -1,41 +1,61 @@
import { useState, useRef, useEffect } from 'react';
import { setAllowed } from '@stellar/freighter-api';
import { useState, useRef, useEffect } from 'react'
import { setAllowed } from '@stellar/freighter-api'

interface WalletButtonProps {
address: string;
onDisconnect: () => void;
address: string
/** The Stellar network name, e.g. "Test SDF Network ; September 2015" */
network?: string | null
/** Called when the user clicks "Disconnect" in the dropdown */
onDisconnect: () => void
/** Label for the switch-account menu item */
switchAccountLabel?: string
/** Label for the disconnect menu item */
disconnectLabel?: string
}

/**
* Shows the connected wallet address and a dropdown with options to switch
* accounts or disconnect. Clicking outside closes the menu.
* Shows the connected wallet address and a dropdown with:
* - Current network indicator
* - Switch account (re-opens Freighter permission popup)
* - Disconnect (clears local connection state)
*
* Clicking outside closes the dropdown.
*/
export function WalletButton({ address, onDisconnect }: WalletButtonProps) {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
export function WalletButton({
address,
network,
onDisconnect,
switchAccountLabel = 'Switch account',
disconnectLabel = 'Disconnect',
}: 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);
setOpen(false)
}
}
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
document.addEventListener('mousedown', handleClickOutside)
return () => document.removeEventListener('mousedown', handleClickOutside)
}, [])

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

// Derive a short, human-readable network label
const networkLabel = deriveNetworkLabel(network)

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

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

return (
Expand All @@ -46,31 +66,80 @@ export function WalletButton({ address, onDisconnect }: WalletButtonProps) {
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" />
{/* Connection indicator */}
<span
className="w-2 h-2 rounded-full bg-green-500"
aria-hidden="true"
title="Connected"
/>
{displayName}
{/* Chevron */}
<svg
className={`w-3.5 h-3.5 text-gray-500 dark:text-gray-400 transition-transform ${
open ? 'rotate-180' : ''
}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 9l-7 7-7-7"
/>
</svg>
</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"
className="absolute right-0 mt-1 w-56 rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 shadow-lg py-1 z-50"
>
{/* Network badge */}
{networkLabel && (
<div className="px-3 py-2 border-b border-gray-100 dark:border-gray-800">
<div className="flex items-center gap-2">
<span className="w-1.5 h-1.5 rounded-full bg-green-400 flex-shrink-0" />
<span className="text-xs text-gray-500 dark:text-gray-400">
{networkLabel}
</span>
</div>
</div>
)}

<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
{switchAccountLabel}
</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
{disconnectLabel}
</button>
</div>
)}
</div>
);
)
}

// ── Helpers ────────────────────────────────────────────────────

/** Derive a short display label from the network passphrase or name. */
function deriveNetworkLabel(
network: string | null | undefined,
): string | null {
if (!network) return null
if (network.includes('Test')) return 'Testnet'
if (network.includes('Future')) return 'Futurenet'
if (network.includes('Public')) return 'Mainnet'
// Fallback: return the first segment
return network.split(';')[0]?.trim() || network
}
Loading
Loading