feat: add unified login UX/UI

This commit is contained in:
p2arthur
2026-01-13 14:15:17 -08:00
parent a6622d2cf8
commit 7bf43ceda6
28 changed files with 4926 additions and 508 deletions

View File

@ -2,143 +2,83 @@ import { useState } from 'react'
import { NavLink, Outlet } from 'react-router-dom'
import ConnectWallet from './components/ConnectWallet'
import ThemeToggle from './components/ThemeToggle'
import Web3AuthButton from './components/Web3AuthButton'
import { useUnifiedWallet } from './hooks/useUnifiedWallet'
/**
* Main Layout Component
* Wraps the entire app with navigation, footer, and wallet connection modal
* Now with unified wallet support - shows mutual exclusion between Web3Auth and traditional wallets
*/
export default function Layout() {
const [openWalletModal, setOpenWalletModal] = useState(false)
const { walletType } = useUnifiedWallet()
const { isConnected, activeAddress, userInfo } = useUnifiedWallet()
const toggleWalletModal = () => setOpenWalletModal(!openWalletModal)
// Determine button states based on which wallet is active
const isWeb3AuthActive = walletType === 'web3auth'
const isTraditionalActive = walletType === 'traditional'
// Helper to format address: "ZBC...WXYZ"
const displayAddress =
isConnected && activeAddress ? `${activeAddress.toString().slice(0, 4)}...${activeAddress.toString().slice(-4)}` : 'Sign in'
return (
<div className="min-h-screen flex flex-col bg-white dark:bg-slate-950 text-slate-900 dark:text-slate-100">
{/* Navbar */}
<nav className="bg-white dark:bg-slate-900 border-b border-slate-200 dark:border-slate-800 sticky top-0 z-50 shadow-sm">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4 flex items-center justify-between">
<NavLink
to="/"
className="text-2xl font-bold text-slate-900 dark:text-white hover:text-teal-600 dark:hover:text-teal-400 transition"
>
<NavLink to="/" className="text-2xl font-bold text-slate-900 dark:text-white hover:text-teal-600 transition">
TokenizeRWA
</NavLink>
<div className="hidden sm:flex items-center gap-6">
<NavLink
to="/"
className={({ isActive }) =>
`text-slate-600 dark:text-slate-400 hover:text-slate-900 dark:hover:text-slate-100 font-medium transition ${isActive ? 'text-slate-900 dark:text-slate-100 border-b-2 border-teal-600' : ''}`
}
>
Home
</NavLink>
<NavLink
to="/tokenize"
className={({ isActive }) =>
`text-slate-600 dark:text-slate-400 hover:text-slate-900 dark:hover:text-slate-100 font-medium transition ${isActive ? 'text-slate-900 dark:text-slate-100 border-b-2 border-teal-600' : ''}`
}
>
Tokenize
</NavLink>
{/* Desktop Navigation */}
<div className="hidden sm:flex items-center gap-8">
{['Home', 'Tokenize'].map((item) => (
<NavLink
key={item}
to={item === 'Home' ? '/' : `/${item.toLowerCase()}`}
className={({ isActive }) =>
`text-sm font-semibold transition ${isActive ? 'text-teal-600' : 'text-slate-500 hover:text-slate-900 dark:hover:text-white'}`
}
>
{item}
</NavLink>
))}
</div>
<div className="flex items-center gap-3">
<div className="flex items-center gap-4">
<ThemeToggle />
{/* Web3Auth Button - disabled if traditional wallet is active */}
<div className={isTraditionalActive ? 'opacity-50 pointer-events-none' : ''}>
<Web3AuthButton />
</div>
{/* Traditional Wallet Button - disabled if Web3Auth is active */}
{/* ONE Button to Rule Them All */}
<button
onClick={toggleWalletModal}
disabled={isWeb3AuthActive}
className={`px-4 py-2 rounded-lg font-medium transition text-sm shadow-sm ${
isWeb3AuthActive
? 'bg-gray-300 text-gray-500 cursor-not-allowed dark:bg-slate-700 dark:text-slate-500'
: isTraditionalActive
? 'bg-teal-600 text-white hover:bg-teal-700'
: 'bg-teal-600 text-white hover:bg-teal-700'
className={`flex items-center gap-2 px-5 py-2 rounded-xl font-bold text-sm transition shadow-sm border ${
isConnected
? 'bg-slate-50 dark:bg-slate-800 border-slate-200 dark:border-slate-700 text-slate-700 dark:text-slate-200'
: 'bg-teal-600 border-teal-600 text-white hover:bg-teal-700'
}`}
title={isWeb3AuthActive ? 'Using Web3Auth - disconnect to use traditional wallet' : undefined}
>
{isWeb3AuthActive
? 'Using Web3Auth'
: isTraditionalActive
? 'Wallet Connected'
: 'Connect Wallet'}
{isConnected && <span className="w-2 h-2 bg-emerald-500 rounded-full animate-pulse" />}
{displayAddress}
</button>
</div>
</div>
</nav>
{/* Main */}
<main className="flex-1">
<Outlet />
</main>
{/* Footer */}
<footer className="bg-slate-900 dark:bg-slate-950 text-slate-300 dark:text-slate-400 border-t border-slate-800 dark:border-slate-800">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<div className="grid gap-8 md:grid-cols-3">
<div>
<div className="text-xl font-bold text-white">TokenizeRWA</div>
<p className="mt-3 text-sm leading-relaxed">
A lightweight proof-of-concept template for tokenizing real-world assets on Algorand.
</p>
</div>
<div>
<div className="font-semibold text-white mb-4">Resources</div>
<ul className="space-y-2 text-sm">
<li>
<NavLink to="/tokenize" className="hover:text-white transition">
Tokenize an Asset
</NavLink>
</li>
<li>
<a
className="hover:text-white transition"
href="https://dev.algorand.co/concepts/assets/overview/"
target="_blank"
rel="noreferrer"
>
ASA Documentation
</a>
</li>
<li>
<a className="hover:text-white transition" href="https://lora.algokit.io/testnet" target="_blank" rel="noreferrer">
Lora Explorer
</a>
</li>
</ul>
</div>
<div>
<div className="font-semibold text-white mb-4">About</div>
<p className="text-sm leading-relaxed">
A POC template for founders. For production, add compliance workflows, identity verification, and audit logs.
</p>
</div>
{/* Footer (Simplified) */}
<footer className="bg-slate-900 text-slate-400 py-12 px-6 border-t border-slate-800">
<div className="max-w-7xl mx-auto grid gap-8 md:grid-cols-3">
<div>
<div className="text-xl font-bold text-white mb-3">TokenizeRWA</div>
<p className="text-sm">POC template for tokenizing real-world assets on Algorand.</p>
</div>
<div className="mt-12 pt-8 border-t border-gray-800 text-xs text-gray-400 flex flex-col sm:flex-row gap-2 sm:justify-between">
<span>© {new Date().getFullYear()} TokenizeRWA. All rights reserved.</span>
<span>Built with AlgoKit + Algorand</span>
<div className="text-sm">
<span className="text-white font-bold block mb-2">Connect</span>
<a href="https://lora.algokit.io" target="_blank" className="hover:text-teal-400 transition">
Lora Explorer
</a>
</div>
<div className="text-xs">© {new Date().getFullYear()} TokenizeRWA. All rights reserved.</div>
</div>
</footer>
{/* The Unified Modal */}
<ConnectWallet openModal={openWalletModal} closeModal={toggleWalletModal} />
</div>
)

View File

@ -1,5 +1,6 @@
import { useWallet, Wallet, WalletId } from '@txnlab/use-wallet-react'
import { useEffect, useRef } from 'react'
import { WalletId } from '@txnlab/use-wallet-react'
import { useEffect, useRef, useState } from 'react'
import { SocialLoginProvider, useUnifiedWallet } from '../hooks/useUnifiedWallet'
import Account from './Account'
interface ConnectWalletInterface {
@ -7,154 +8,160 @@ interface ConnectWalletInterface {
closeModal: () => void
}
/**
* ConnectWallet Modal Component
* Displays wallet connection options (Pera, Defly, Lute, KMD for LocalNet)
* Also shows connected wallet details and network information when logged in
*/
const ConnectWallet = ({ openModal, closeModal }: ConnectWalletInterface) => {
const { wallets, activeAddress } = useWallet()
// Destructure the new clean methods from your unified hook
const { isConnected, walletType, userInfo, traditionalWallets, connectGoogle, connectFacebook, connectGithub, disconnect } =
useUnifiedWallet()
const [connectingProvider, setConnectingProvider] = useState<SocialLoginProvider | null>(null)
const dialogRef = useRef<HTMLDialogElement>(null)
// Manage native dialog element's open/close state
const handleSocialLogin = async (provider: SocialLoginProvider, connectFn: () => Promise<void>) => {
try {
setConnectingProvider(provider)
await connectFn() // Bypasses the Web3Auth modal
closeModal()
} catch (error) {
console.error(`${provider} login failed`, error)
} finally {
setConnectingProvider(null)
}
}
const socialOptions: { id: SocialLoginProvider; label: string; icon: string; action: () => Promise<void> }[] = [
{
id: 'google',
label: 'Continue with Google',
icon: 'https://www.gstatic.com/images/branding/product/1x/gsa_512dp.png',
action: connectGoogle,
},
{
id: 'facebook',
label: 'Continue with Facebook',
icon: 'https://www.facebook.com/images/fb_icon_325x325.png',
action: connectFacebook,
},
{
id: 'github',
label: 'Continue with GitHub',
icon: 'https://github.githubassets.com/images/modules/logos_page/GitHub-Mark.png',
action: connectGithub,
},
]
const formatSocialProvider = (provider?: string) => {
const normalized = provider?.toLowerCase()
switch (normalized) {
case 'google':
return 'Google'
case 'facebook':
return 'Facebook'
case 'github':
return 'GitHub'
default:
return 'Social Login'
}
}
useEffect(() => {
const dialog = dialogRef.current
if (!dialog) return
if (openModal) {
dialog.showModal()
} else {
dialog.close()
}
openModal ? dialog.showModal() : dialog.close()
}, [openModal])
const getActiveWallet = () => {
if (!wallets) return null
return wallets.find((w) => w.isActive)
}
const getWalletDisplayName = (wallet: Wallet) => {
if (wallet.id === WalletId.KMD) return 'LocalNet Wallet'
return wallet.metadata.name
}
const isKmd = (wallet: Wallet) => wallet.id === WalletId.KMD
const activeWallet = getActiveWallet()
return (
<dialog
ref={dialogRef}
id="connect_wallet_modal"
className="fixed inset-0 w-full max-w-lg mx-auto my-auto rounded-2xl bg-white dark:bg-slate-800 shadow-2xl border border-gray-200 dark:border-slate-700 overflow-hidden"
onClick={(e) => {
// Close when clicking the backdrop
if (e.target === dialogRef.current) {
closeModal()
}
}}
className="fixed inset-0 w-full max-w-md mx-auto my-auto rounded-3xl bg-white dark:bg-slate-900 shadow-2xl border-none p-0 backdrop:bg-gray-900/50 backdrop:backdrop-blur-sm"
onClick={(e) => e.target === dialogRef.current && closeModal()}
>
<div className="p-6 sm:p-7">
<div className="flex items-center justify-between mb-5">
<h3 className="text-xl font-semibold text-gray-900 dark:text-slate-100">Select wallet provider</h3>
<button
className="text-gray-400 dark:text-slate-500 hover:text-gray-600 dark:hover:text-slate-300 transition text-sm"
onClick={closeModal}
aria-label="Close wallet modal"
>
<div className="p-8">
<div className="flex items-center justify-between mb-6">
<h3 className="text-2xl font-bold text-gray-900 dark:text-white">{isConnected ? 'Account' : 'Sign in'}</h3>
<button onClick={closeModal} className="p-2 hover:bg-gray-100 dark:hover:bg-slate-800 rounded-full transition">
<span className="text-xl text-gray-500"></span>
</button>
</div>
<p className="text-sm text-gray-500 dark:text-slate-400 mb-4">
Choose the wallet you want to connect. Supported: Pera, Defly, LocalNet (KMD), and others.
</p>
<div className="space-y-4">
{activeAddress && (
<>
<div className="rounded-xl border border-gray-100 dark:border-slate-700 bg-gray-50 dark:bg-slate-700 p-4">
<div className="space-y-6">
{isConnected ? (
/* --- CONNECTED STATE --- */
<div className="space-y-4">
<div className="bg-slate-50 dark:bg-slate-800 rounded-2xl p-4 border border-slate-100 dark:border-slate-700">
<Account />
</div>
{/* Wallet Info */}
<div className="space-y-3 bg-white dark:bg-slate-700 border border-gray-200 dark:border-slate-600 rounded-xl p-4">
{activeWallet && (
<div>
<p className="text-xs text-gray-500 dark:text-slate-400 font-medium mb-1">Connected Wallet</p>
<p className="text-sm font-semibold text-gray-900 dark:text-slate-100">{getWalletDisplayName(activeWallet)}</p>
{walletType === 'web3auth' && userInfo && (
<div className="mt-3 pt-3 border-t border-slate-200 dark:border-slate-700 flex items-center gap-3">
{userInfo.profileImage && (
<img src={userInfo.profileImage} alt="Profile" className="w-8 h-8 rounded-full border border-white" />
)}
<div className="overflow-hidden">
<p className="text-[10px] uppercase tracking-wider text-gray-500 font-bold">
Connected via {formatSocialProvider(userInfo.typeOfLogin)}
</p>
<p className="text-sm font-medium dark:text-slate-200 truncate">{userInfo.email || userInfo.name}</p>
</div>
</div>
)}
</div>
<div className="h-px bg-gray-200 dark:bg-slate-600" />
</>
)}
{!activeAddress &&
wallets?.map((wallet) => (
<button
data-test-id={`${wallet.id}-connect`}
className={`
w-full flex items-center gap-4 px-4 py-3 rounded-xl bg-white dark:bg-slate-700 border border-gray-200 dark:border-slate-600
hover:border-indigo-200 dark:hover:border-indigo-500 hover:bg-indigo-50/50 dark:hover:bg-slate-600 transition
focus:outline-none focus:ring-2 focus:ring-indigo-200 dark:focus:ring-indigo-500
`}
key={`provider-${wallet.id}`}
onClick={() => {
// Close modal before initiating wallet connection
closeModal()
wallet.connect()
}}
onClick={disconnect} // Use the unified disconnect method
className="w-full py-3 bg-red-50 text-red-600 hover:bg-red-100 dark:bg-red-900/20 dark:text-red-400 font-semibold rounded-xl transition"
>
{!isKmd(wallet) && (
<img
alt={`wallet_icon_${wallet.id}`}
src={wallet.metadata.icon}
className="w-9 h-9 object-contain rounded-md border border-gray-100 dark:border-slate-600 bg-white dark:bg-slate-700"
/>
)}
<span className="font-medium text-sm text-left flex-1 text-gray-900 dark:text-slate-100">
{isKmd(wallet) ? 'LocalNet Wallet' : wallet.metadata.name}
</span>
{wallet.isActive && <span className="text-sm text-emerald-500"></span>}
Disconnect
</button>
))}
</div>
</div>
) : (
/* --- DISCONNECTED STATE --- */
<>
<div className="space-y-3">
<p className="text-xs font-bold uppercase tracking-wider text-gray-400 px-1">Social Login</p>
{socialOptions.map((option) => (
<button
key={option.id}
onClick={() => handleSocialLogin(option.id, option.action)}
disabled={!!connectingProvider}
className="w-full flex items-center justify-center gap-3 px-4 py-3.5 rounded-xl border border-gray-200 dark:border-slate-700 hover:bg-gray-50 dark:hover:bg-slate-800 transition shadow-sm font-medium text-gray-700 dark:text-slate-200 disabled:opacity-50"
>
<img src={option.icon} className="w-5 h-5" alt={option.label} />
{connectingProvider === option.id ? 'Connecting...' : option.label}
</button>
))}
</div>
<div className="mt-6 flex gap-3">
<button
data-test-id="close-wallet-modal"
className="w-full sm:w-auto px-4 py-2.5 rounded-lg border border-gray-200 dark:border-slate-600 bg-gray-50 dark:bg-slate-700 text-gray-700 dark:text-slate-300 text-sm hover:bg-gray-100 dark:hover:bg-slate-600 transition"
onClick={() => {
closeModal()
}}
>
Close
</button>
<div className="relative">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-gray-100 dark:border-slate-800"></div>
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-white dark:bg-slate-900 px-2 text-gray-400">Or use a wallet</span>
</div>
</div>
{activeAddress && (
<button
className="w-full sm:w-auto px-4 py-2.5 rounded-lg bg-red-500 hover:bg-red-600 dark:bg-red-600 dark:hover:bg-red-700 text-white text-sm transition"
data-test-id="logout"
onClick={async () => {
if (wallets) {
const wallet = wallets.find((w) => w.isActive)
if (wallet) {
await wallet.disconnect()
} else {
localStorage.removeItem('@txnlab/use-wallet:v3')
window.location.reload()
}
}
}}
>
Logout
</button>
<div className="grid grid-cols-1 gap-3">
{traditionalWallets?.map((wallet) => (
<button
key={wallet.id}
className="flex items-center gap-4 p-4 rounded-xl border border-gray-100 dark:border-slate-800 hover:border-indigo-500 dark:hover:border-indigo-500 hover:bg-indigo-50/30 transition group"
onClick={() => {
closeModal()
wallet.connect()
}}
>
<img src={wallet.metadata.icon} alt={wallet.id} className="w-10 h-10 rounded-lg group-hover:scale-110 transition" />
<span className="font-semibold text-gray-800 dark:text-slate-200">
{wallet.id === WalletId.KMD ? 'LocalNet' : wallet.metadata.name}
</span>
</button>
))}
</div>
</>
)}
</div>
</div>
</dialog>
)
}
export default ConnectWallet

View File

@ -1,6 +1,6 @@
import { useEffect, useState } from 'react'
import { AiOutlineLoading3Quarters } from 'react-icons/ai'
import { FaGoogle, FaCopy, FaCheck } from 'react-icons/fa'
import { FaCheck, FaCopy, FaGoogle } from 'react-icons/fa'
import { useWeb3Auth } from './Web3AuthProvider'
/**
@ -46,12 +46,12 @@ export function Web3AuthButton() {
// Handle if address is an object (like from algosdk with publicKey property)
if (typeof algorandAccount.address === 'object' && algorandAccount.address !== null) {
// If it has a toString method, use it
if ('toString' in algorandAccount.address && typeof algorandAccount.address.toString === 'function') {
return algorandAccount.address.toString()
if ('toString' in algorandAccount.address && typeof algorandAccount.address === 'function') {
return algorandAccount.address
}
// If it has an addr property (algosdk Account object)
if ('addr' in algorandAccount.address) {
return String(algorandAccount.address.addr)
return String(algorandAccount.address)
}
return ''
}
@ -59,14 +59,6 @@ export function Web3AuthButton() {
return String(algorandAccount.address)
}
// Ellipsize long addresses for better UI
const ellipseAddress = (address: string = '', startChars = 6, endChars = 4): string => {
if (!address || address.length <= startChars + endChars) {
return address
}
return `${address.slice(0, startChars)}...${address.slice(-endChars)}`
}
// Handle login with error feedback
const handleLogin = async () => {
try {
@ -149,7 +141,7 @@ export function Web3AuthButton() {
<button
onClick={() => setIsDropdownOpen(!isDropdownOpen)}
className="btn btn-sm btn-ghost gap-2 hover:bg-base-200"
title={`Connected: ${address}`}
title={`Connected: ${address} ${userInfo?.email}`}
>
<div className="flex items-center gap-2">
{/* Profile picture - always show first letter of address */}
@ -164,7 +156,7 @@ export function Web3AuthButton() {
{firstLetter}
</div>
)}
<span className="font-mono text-sm font-medium">{ellipseAddress(address)}</span>
<span className="font-mono text-sm font-medium">{address}</span>
<svg
className={`w-4 h-4 transition-transform ${isDropdownOpen ? 'rotate-180' : ''}`}
fill="none"
@ -184,23 +176,15 @@ export function Web3AuthButton() {
<li className="menu-title px-3 py-2">
<div className="flex items-center gap-3">
{userInfo.profileImage ? (
<img
src={userInfo.profileImage}
alt="Profile"
className="w-10 h-10 rounded-full object-cover ring-2 ring-primary"
/>
<img src={userInfo.profileImage} alt="Profile" className="w-10 h-10 rounded-full object-cover ring-2 ring-primary" />
) : (
<div className="w-10 h-10 rounded-full bg-primary text-primary-content flex items-center justify-center text-lg font-bold">
{firstLetter}
</div>
)}
<div className="flex flex-col">
{userInfo.name && (
<span className="font-semibold text-base-content">{userInfo.name}</span>
)}
{userInfo.email && (
<span className="text-xs text-base-content/70 break-all">{userInfo.email}</span>
)}
{userInfo.name && <span className="font-semibold text-base-content">{userInfo.name}</span>}
{userInfo.email && <span className="text-xs text-base-content/70 break-all">{userInfo.email}</span>}
</div>
</div>
</li>
@ -213,17 +197,12 @@ export function Web3AuthButton() {
<span className="text-xs uppercase">Algorand Address</span>
</li>
<li>
<div className="bg-base-200 rounded-lg p-2 font-mono text-xs break-all cursor-default hover:bg-base-200">
{address}
</div>
<div className="bg-base-200 rounded-lg p-2 font-mono text-xs break-all cursor-default hover:bg-base-200">{address}</div>
</li>
{/* Copy Address Button */}
<li>
<button
onClick={handleCopyAddress}
className="text-sm gap-2"
>
<button onClick={handleCopyAddress} className="text-sm gap-2">
{copied ? (
<>
<FaCheck className="text-success" />
@ -242,11 +221,7 @@ export function Web3AuthButton() {
{/* Disconnect Button */}
<li>
<button
onClick={handleLogout}
disabled={isLoading}
className="text-sm text-error hover:bg-error/10 gap-2"
>
<button onClick={handleLogout} disabled={isLoading} className="text-sm text-error hover:bg-error/10 gap-2">
{isLoading ? (
<>
<AiOutlineLoading3Quarters className="animate-spin" />
@ -255,7 +230,12 @@ export function Web3AuthButton() {
) : (
<>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"
/>
</svg>
<span>Disconnect</span>
</>

View File

@ -13,7 +13,11 @@ interface Web3AuthContextType {
web3AuthInstance: Web3Auth | null
algorandAccount: AlgorandAccountFromWeb3Auth | null
userInfo: Web3AuthUserInfo | null
login: () => Promise<void>
/**
* login handles both modal and direct social login.
* Passing arguments bypasses the Web3Auth modal.
*/
login: (adapter?: string, provider?: string) => Promise<void>
logout: () => Promise<void>
refreshUserInfo: () => Promise<void>
}
@ -31,6 +35,7 @@ export function Web3AuthProvider({ children }: { children: ReactNode }) {
const [algorandAccount, setAlgorandAccount] = useState<AlgorandAccountFromWeb3Auth | null>(null)
const [userInfo, setUserInfo] = useState<Web3AuthUserInfo | null>(null)
// Initialization logic
useEffect(() => {
const initializeWeb3Auth = async () => {
try {
@ -38,7 +43,6 @@ export function Web3AuthProvider({ children }: { children: ReactNode }) {
setError(null)
const web3auth = await initWeb3Auth()
setWeb3AuthInstance(web3auth)
if (web3auth.status === 'connected' && web3auth.provider) {
@ -49,19 +53,17 @@ export function Web3AuthProvider({ children }: { children: ReactNode }) {
const account = await getAlgorandAccount(web3auth.provider)
setAlgorandAccount(account)
} catch (err) {
console.error('🎯 Account derivation error:', err)
setError('Failed to derive Algorand account. Please reconnect.')
}
try {
const userInformation = await getWeb3AuthUserInfo()
if (userInformation) {
setUserInfo(userInformation)
}
if (userInformation) setUserInfo(userInformation)
} catch (err) {
console.error('🎯 Failed to fetch user info:', err)
}
}
setIsInitialized(true)
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to initialize Web3Auth'
@ -76,25 +78,32 @@ export function Web3AuthProvider({ children }: { children: ReactNode }) {
initializeWeb3Auth()
}, [])
const login = async () => {
/**
* Unified Login Function
* @param adapter - (Optional) e.g., WALLET_ADAPTERS.AUTH
* @param loginProvider - (Optional) e.g., 'google'
*/
const login = async (adapter?: string, loginProvider?: string) => {
if (!web3AuthInstance) {
console.error('🎯 LOGIN: Web3Auth not initialized')
setError('Web3Auth not initialized')
return
}
if (!isInitialized) {
console.error('🎯 LOGIN: Web3Auth still initializing')
setError('Web3Auth is still initializing, please try again')
return
}
try {
setIsLoading(true)
setError(null)
const web3authProvider = await web3AuthInstance.connect()
let web3authProvider: IProvider | null
// Check if we are triggering a specific social login (bypasses modal)
if (adapter && loginProvider) {
web3authProvider = await web3AuthInstance.connectTo(adapter, {
loginProvider: loginProvider,
})
} else {
// Fallback to showing the default Web3Auth Modal
web3authProvider = await web3AuthInstance.connect()
}
if (!web3authProvider) {
throw new Error('Failed to connect Web3Auth provider')
@ -103,23 +112,12 @@ export function Web3AuthProvider({ children }: { children: ReactNode }) {
setProvider(web3authProvider)
setIsConnected(true)
try {
const account = await getAlgorandAccount(web3authProvider)
setAlgorandAccount(account)
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to derive Algorand account'
setError(errorMessage)
}
try {
const userInformation = await getWeb3AuthUserInfo()
if (userInformation) {
setUserInfo(userInformation)
}
} catch (err) {
console.error('🎯 LOGIN: Failed to fetch user info:', err)
}
// Post-connection: Derive Algorand Address and Fetch Profile
const account = await getAlgorandAccount(web3authProvider)
setAlgorandAccount(account)
const userInformation = await getWeb3AuthUserInfo()
if (userInformation) setUserInfo(userInformation)
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Login failed'
console.error('🎯 LOGIN: Error:', err)
@ -143,16 +141,9 @@ export function Web3AuthProvider({ children }: { children: ReactNode }) {
setIsConnected(false)
setAlgorandAccount(null)
setUserInfo(null)
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Logout failed'
console.error('🎯 LOGOUT: Error:', err)
setError(errorMessage)
setProvider(null)
setIsConnected(false)
setAlgorandAccount(null)
setUserInfo(null)
setError(err instanceof Error ? err.message : 'Logout failed')
} finally {
setIsLoading(false)
}
@ -161,9 +152,7 @@ export function Web3AuthProvider({ children }: { children: ReactNode }) {
const refreshUserInfo = async () => {
try {
const userInformation = await getWeb3AuthUserInfo()
if (userInformation) {
setUserInfo(userInformation)
}
if (userInformation) setUserInfo(userInformation)
} catch (err) {
console.error('🎯 REFRESH: Failed:', err)
}
@ -188,10 +177,8 @@ export function Web3AuthProvider({ children }: { children: ReactNode }) {
export function useWeb3Auth(): Web3AuthContextType {
const context = useContext(Web3AuthContext)
if (context === undefined) {
throw new Error('useWeb3Auth must be used within a Web3AuthProvider')
}
return context
}

View File

@ -1,219 +1,47 @@
/**
* Unified Wallet Hook
*
* Combines Web3Auth (Google OAuth) and traditional wallet (Pera/Defly/etc) into ONE interface.
* Provides a single source of truth for activeAddress and signer across the entire app.
*
* Features:
* - Returns ONE activeAddress (from either Web3Auth OR traditional wallet)
* - Returns ONE signer (compatible with AlgorandClient)
* - Indicates which wallet type is active
* - Handles mutual exclusion (only one can be active at a time)
*
* Usage:
* ```typescript
* const { activeAddress, signer, walletType, isConnected } = useUnifiedWallet()
*
* // walletType will be: 'web3auth' | 'traditional' | null
* // signer works with: algorand.send.assetCreate({ sender: activeAddress, signer, ... })
* ```
*/
import { useWallet } from '@txnlab/use-wallet-react'
import { useMemo } from 'react'
import { useWeb3Auth } from '../components/Web3AuthProvider'
import { createWeb3AuthSigner } from '../utils/web3auth/web3authIntegration'
import { WALLET_ADAPTERS } from '@web3auth/base'
export type WalletType = 'web3auth' | 'traditional' | null
export type SocialLoginProvider = 'google' | 'facebook' | 'github'
export interface UnifiedWalletState {
/** The active Algorand address (from either Web3Auth or traditional wallet) */
activeAddress: string | null
/** Transaction signer compatible with AlgorandClient */
signer: any | null
/** Which wallet system is currently active */
walletType: WalletType
/** Whether any wallet is connected */
isConnected: boolean
/** Loading state (either wallet system initializing/connecting) */
isLoading: boolean
/** Error from either wallet system */
error: string | null
/** Original Web3Auth data (for accessing userInfo, etc) */
web3auth: {
algorandAccount: ReturnType<typeof useWeb3Auth>['algorandAccount']
userInfo: ReturnType<typeof useWeb3Auth>['userInfo']
login: ReturnType<typeof useWeb3Auth>['login']
logout: ReturnType<typeof useWeb3Auth>['logout']
}
/** Original traditional wallet data (for accessing wallet-specific features) */
traditional: {
wallets: ReturnType<typeof useWallet>['wallets']
activeWallet: ReturnType<typeof useWallet>['activeWallet']
}
}
/**
* useUnifiedWallet Hook
*
* Combines Web3Auth and traditional wallet into a single interface.
* Priority: Web3Auth takes precedence if both are somehow connected.
*
* @returns UnifiedWalletState with activeAddress, signer, and wallet metadata
*
* @example
* ```typescript
* // In TokenizeAsset.tsx:
* const { activeAddress, signer, walletType } = useUnifiedWallet()
*
* if (!activeAddress) {
* return <p>Please connect a wallet</p>
* }
*
* // Use signer with AlgorandClient - works with BOTH wallet types!
* const result = await algorand.send.assetCreate({
* sender: activeAddress,
* signer: signer,
* total: BigInt(1000000),
* decimals: 6,
* assetName: 'My Token',
* unitName: 'MYT',
* })
* ```
*/
export function useUnifiedWallet(): UnifiedWalletState {
// Get both wallet systems
const web3auth = useWeb3Auth()
export function useUnifiedWallet() {
const { isConnected, algorandAccount, userInfo, login, logout, isLoading } = useWeb3Auth()
const traditional = useWallet()
// Compute unified state
const state = useMemo<UnifiedWalletState>(() => {
// Priority 1: Web3Auth (if connected)
if (web3auth.isConnected && web3auth.algorandAccount) {
return {
activeAddress: web3auth.algorandAccount.address,
signer: createWeb3AuthSigner(web3auth.algorandAccount),
walletType: 'web3auth',
isConnected: true,
isLoading: web3auth.isLoading,
error: web3auth.error,
web3auth: {
algorandAccount: web3auth.algorandAccount,
userInfo: web3auth.userInfo,
login: web3auth.login,
logout: web3auth.logout,
},
traditional: {
wallets: traditional.wallets,
activeWallet: traditional.activeWallet,
},
}
return useMemo(() => {
// Determine which source is actually providing an account
const isWeb3AuthActive = isConnected && !!algorandAccount
const isTraditionalActive = !!traditional.activeAddress
const activeAddress = isWeb3AuthActive ? algorandAccount!.address : traditional.activeAddress || null
const connectWithSocial = async (provider: SocialLoginProvider) => {
await login(WALLET_ADAPTERS.AUTH, provider)
}
// Priority 2: Traditional wallet (Pera/Defly/etc)
if (traditional.activeAddress) {
return {
activeAddress: traditional.activeAddress,
signer: traditional.transactionSigner,
walletType: 'traditional',
isConnected: true,
isLoading: false,
error: null,
web3auth: {
algorandAccount: null,
userInfo: null,
login: web3auth.login,
logout: web3auth.logout,
},
traditional: {
wallets: traditional.wallets,
activeWallet: traditional.activeWallet,
},
}
}
// No wallet connected
return {
activeAddress: null,
signer: null,
walletType: null,
isConnected: false,
isLoading: web3auth.isLoading,
error: web3auth.error,
web3auth: {
algorandAccount: null,
userInfo: null,
login: web3auth.login,
logout: web3auth.logout,
},
traditional: {
wallets: traditional.wallets,
activeWallet: traditional.activeWallet,
activeAddress,
isConnected: !!activeAddress,
walletType: isWeb3AuthActive ? 'web3auth' : isTraditionalActive ? 'traditional' : null,
isLoading,
// Connection Methods
connectSocial: connectWithSocial,
connectGoogle: async () => connectWithSocial('google'),
connectFacebook: async () => connectWithSocial('facebook'),
connectGithub: async () => connectWithSocial('github'),
disconnect: async () => {
if (isWeb3AuthActive) await logout()
if (isTraditionalActive) await traditional.activeWallet?.disconnect()
},
// Metadata
userInfo,
traditionalWallets: traditional.wallets,
signer: isWeb3AuthActive ? createWeb3AuthSigner(algorandAccount) : traditional.transactionSigner,
}
}, [
web3auth.isConnected,
web3auth.algorandAccount,
web3auth.isLoading,
web3auth.error,
web3auth.userInfo,
web3auth.login,
web3auth.logout,
traditional.activeAddress,
traditional.transactionSigner,
traditional.wallets,
traditional.activeWallet,
])
return state
}
/**
* Helper hook: Get just the address (most common use case)
*
* @example
* ```typescript
* const address = useActiveAddress()
* if (!address) return <ConnectButton />
* ```
*/
export function useActiveAddress(): string | null {
const { activeAddress } = useUnifiedWallet()
return activeAddress
}
/**
* Helper hook: Check if any wallet is connected
*
* @example
* ```typescript
* const isConnected = useIsWalletConnected()
* ```
*/
export function useIsWalletConnected(): boolean {
const { isConnected } = useUnifiedWallet()
return isConnected
}
/**
* Helper hook: Get wallet type
*
* @example
* ```typescript
* const walletType = useWalletType()
* if (walletType === 'web3auth') {
* // Show Google profile info
* }
* ```
*/
export function useWalletType(): WalletType {
const { walletType } = useUnifiedWallet()
return walletType
}, [isConnected, algorandAccount, userInfo, traditional, isLoading])
}

View File

@ -45,7 +45,7 @@ export async function initWeb3Auth(): Promise<Web3Auth> {
primary: '#000000',
},
mode: 'light' as const,
loginMethodsOrder: ['google', 'github', 'twitter'],
loginMethodsOrder: ['google', 'facebook', 'github', 'twitter'],
defaultLanguage: 'en',
},
}
@ -78,6 +78,7 @@ export interface Web3AuthUserInfo {
email?: string
name?: string
profileImage?: string
typeOfLogin?: string
[key: string]: unknown
}