diff --git a/STELLAR_WALLETS_KIT_IMPLEMENTATION.md b/STELLAR_WALLETS_KIT_IMPLEMENTATION.md new file mode 100644 index 000000000..ce548f5dc --- /dev/null +++ b/STELLAR_WALLETS_KIT_IMPLEMENTATION.md @@ -0,0 +1,456 @@ +# Add Stellar Wallets Kit Implementation + +**Priority:** High +**Type:** New Implementation +**Estimated Time:** 3-4 days +**Dependencies:** None +**Current Status:** To Implement + +## Context + +The current wallet integration is limited to Freighter only, using `@stellar/freighter-api`. This creates several limitations: + +- **Single wallet support**: Users can only connect with Freighter, excluding users with other Stellar wallets (Albedo, Rabet, etc.) +- **Limited functionality**: No message signing, auth entry signing for Soroban, or network switching capabilities +- **Poor user experience**: No wallet selection modal, limited error handling, and no real-time wallet state updates +- **Maintenance overhead**: Direct Freighter API integration requires manual updates and lacks the benefits of a unified wallet kit + +The project needs a comprehensive multi-wallet solution that provides better user experience, enhanced functionality, and easier maintenance. + +## What Needs to be Done + +Implement a complete Stellar Wallets Kit integration that: + +### Core Requirements + +- **Multi-wallet support**: Enable connection with Freighter, Albedo, Rabet, and other Stellar wallets +- **Network management**: Allow users to switch between Testnet and Public networks +- **Enhanced signing**: Support transaction signing, message signing, and auth entry signing for Soroban +- **Real-time updates**: Provide live wallet status and connection state management + +### User Experience Improvements + +- **Wallet selection modal**: Allow users to choose from available wallets with availability detection +- **Network selector**: UI component for switching between testnet and public networks +- **Better error handling**: Comprehensive error messages and user feedback +- **Status indicators**: Real-time connection status and wallet information display + +### Technical Enhancements + +- **Unified API**: Replace direct Freighter API with Stellar Wallets Kit for consistent interface +- **Type safety**: Complete TypeScript types for all wallet operations +- **Testing coverage**: Comprehensive test suite for wallet functionality +- **Documentation**: Updated documentation with usage examples and migration guides + +### Expected Outcomes + +- Users can connect with any supported Stellar wallet +- Seamless network switching between testnet and public +- Enhanced signing capabilities for advanced Stellar features +- Improved error handling and user feedback +- Better maintainability and future wallet additions + +## 📋 Current State Analysis + +### Existing Implementation + +- **Current:** Freighter-only wallet integration using `@stellar/freighter-api` +- **Location:** `hooks/use-wallet.ts`, `components/wallet/WalletConnectButton.tsx` +- **Dependencies:** `@stellar/freighter-api`, `@stellar/stellar-sdk`, `zustand` + +### What's Missing + +- Multi-wallet support (currently only Freighter) +- Stellar Wallets Kit integration +- Wallet selection modal +- Network switching functionality +- Message signing capabilities +- Auth entry signing for Soroban +- Wallet availability detection +- Enhanced error handling and user feedback +- Real-time wallet state updates +- Comprehensive test coverage + +## 🚀 Implementation Plan + +### Phase 1: Core Setup & Dependencies + +#### 1.1 Update Dependencies + +```bash +# Remove old dependencies +npm uninstall @stellar/freighter-api + +# Install Stellar Wallets Kit +npm install @creit.tech/stellar-wallets-kit +``` + +#### 1.2 Environment Configuration + +Create/update `.env.local`: + +```env +# Stellar Wallets Kit Configuration +NEXT_PUBLIC_STELLAR_NETWORK=testnet +NEXT_PUBLIC_HORIZON_URL=https://horizon-testnet.stellar.org +NEXT_PUBLIC_SOROBAN_RPC_URL=https://soroban-testnet.stellar.org +NEXT_PUBLIC_APP_URL=http://localhost:3000 +``` + +### Phase 2: Core Wallet Context & Store + +#### 2.1 Create Enhanced Wallet Context + +**File:** `hooks/use-wallet.ts` + +Replace current Zustand store with Stellar Wallets Kit integration: + +```typescript +import { StellarWalletsKit, WalletData } from '@creit.tech/stellar-wallets-kit'; +import { create } from 'zustand'; + +interface WalletState { + // Core state + isConnected: boolean; + publicKey: string | null; + network: 'testnet' | 'public'; + selectedWallet: string | null; + availableWallets: string[]; + + // Actions + connectWallet: (walletName: string) => Promise; + disconnectWallet: () => void; + switchNetwork: (network: 'testnet' | 'public') => Promise; + signTransaction: (xdr: string) => Promise; + signMessage: (message: string) => Promise; + signAuthEntry: (entry: string) => Promise; + + // Error handling + error: string | null; + setError: (error: string | null) => void; + clearError: () => void; +} +``` + +#### 2.2 Create Wallet Utilities + +**File:** `lib/wallet/utils.ts` + +```typescript +export function formatAddress(address: string): string { + return `${address.slice(0, 6)}...${address.slice(-4)}`; +} + +export function validateAddress(address: string): boolean { + return /^[1-9A-HJ-NP-Za-km-z]{56}$/.test(address); +} + +export function getExplorerUrl( + address: string, + network: 'testnet' | 'public' +): string { + const baseUrl = + network === 'testnet' + ? 'https://testnet.stellarchain.io' + : 'https://stellarchain.io'; + return `${baseUrl}/address/${address}`; +} + +export function getNetworkPassphrase(network: 'testnet' | 'public'): string { + return network === 'testnet' + ? 'Test SDF Network ; September 2015' + : 'Public Global Stellar Network ; September 2015'; +} +``` + +### Phase 3: Enhanced UI Components + +#### 3.1 Update Wallet Connect Button + +**File:** `components/wallet/WalletConnectButton.tsx` + +Enhance with: + +- Wallet selection dropdown +- Network switching +- Real-time status indicators +- Better error handling + +#### 3.2 Create Wallet Selection Modal + +**File:** `components/wallet/WalletSelectionModal.tsx` + +```typescript +interface WalletSelectionModalProps { + isOpen: boolean; + onClose: () => void; + onWalletSelect: (walletName: string) => void; +} + +export function WalletSelectionModal({ + isOpen, + onClose, + onWalletSelect, +}: WalletSelectionModalProps) { + // Implementation with available/unavailable wallet sections + // Network toggle (Testnet/Public) + // Wallet icons and descriptions +} +``` + +#### 3.3 Create Network Selector + +**File:** `components/wallet/NetworkSelector.tsx` + +```typescript +interface NetworkSelectorProps { + currentNetwork: 'testnet' | 'public'; + onNetworkChange: (network: 'testnet' | 'public') => void; + disabled?: boolean; +} +``` + +### Phase 4: Advanced Features + +#### 4.1 Message Signing Component + +**File:** `components/wallet/MessageSigningButton.tsx` + +```typescript +interface MessageSigningButtonProps { + message: string; + onSigned: (signature: string) => void; + disabled?: boolean; +} +``` + +#### 4.2 Auth Entry Signing Component + +**File:** `components/wallet/AuthEntrySigningButton.tsx` + +```typescript +interface AuthEntrySigningButtonProps { + authEntry: string; + onSigned: (signature: string) => void; + disabled?: boolean; +} +``` + +#### 4.3 Wallet Status Component + +**File:** `components/wallet/WalletStatus.tsx` + +```typescript +interface WalletStatusProps { + showNetwork?: boolean; + showAddress?: boolean; + compact?: boolean; +} +``` + +### Phase 5: Hooks & Utilities + +#### 5.1 Create Wallet Hooks + +**File:** `hooks/use-wallet-actions.ts` + +```typescript +export function useWalletActions() { + // Transaction signing + const signTransaction = useCallback(async (xdr: string) => { + // Implementation + }, []); + + // Message signing + const signMessage = useCallback(async (message: string) => { + // Implementation + }, []); + + // Auth entry signing + const signAuthEntry = useCallback(async (entry: string) => { + // Implementation + }, []); + + return { signTransaction, signMessage, signAuthEntry }; +} +``` + +**File:** `hooks/use-wallet-connection.ts` + +```typescript +export function useWalletConnection() { + // Connect wallet + const connectWallet = useCallback(async (walletName: string) => { + // Implementation + }, []); + + // Disconnect wallet + const disconnectWallet = useCallback(() => { + // Implementation + }, []); + + // Switch network + const switchNetwork = useCallback(async (network: 'testnet' | 'public') => { + // Implementation + }, []); + + return { connectWallet, disconnectWallet, switchNetwork }; +} +``` + +### Phase 6: Testing & Documentation + +#### 6.1 Create Test Suite + +**File:** `__tests__/wallet.test.tsx` + +```typescript +describe('Wallet Integration', () => { + test('should connect to Freighter wallet', async () => { + // Test implementation + }); + + test('should handle connection errors', async () => { + // Test implementation + }); + + test('should sign transactions', async () => { + // Test implementation + }); + + test('should switch networks', async () => { + // Test implementation + }); +}); +``` + +#### 6.2 Update Documentation + +**File:** `docs/WALLET_INTEGRATION.md` + +Update with: + +- Stellar Wallets Kit usage +- Multi-wallet support +- Network switching +- Message and auth entry signing +- Error handling patterns +- Testing guidelines + +## 📋 Implementation Checklist + +### ✅ Setup Phase + +- [ ] Update dependencies (remove Freighter API, add Stellar Wallets Kit) +- [ ] Configure environment variables +- [ ] Add wallet icons to `public/icons/` +- [ ] Update TypeScript types + +### ✅ Core Implementation + +- [ ] Replace wallet context with Stellar Wallets Kit +- [ ] Implement multi-wallet support +- [ ] Add network switching functionality +- [ ] Implement message and auth entry signing +- [ ] Add wallet availability detection +- [ ] Create wallet utilities and helpers + +### ✅ UI Components + +- [ ] Update WalletConnectButton with dropdowns +- [ ] Create WalletSelectionModal +- [ ] Add NetworkSelector component +- [ ] Create MessageSigningButton +- [ ] Create AuthEntrySigningButton +- [ ] Add WalletStatus component +- [ ] Update error handling components + +### ✅ Hooks & Utilities + +- [ ] Create useWalletActions hook +- [ ] Create useWalletConnection hook +- [ ] Add wallet utility functions +- [ ] Create address formatting utilities +- [ ] Add explorer URL generators + +### ✅ Testing & Documentation + +- [ ] Write comprehensive test suite +- [ ] Add TypeScript type definitions +- [ ] Update documentation +- [ ] Add usage examples +- [ ] Document error handling patterns + +## 🎯 Expected Outcomes + +### Immediate Benefits + +- ✅ Support for all major Stellar wallets (Freighter, Albedo, Rabet, etc.) +- ✅ Network switching (Testnet/Public) +- ✅ Improved error handling and user feedback +- ✅ Message and auth entry signing capabilities +- ✅ Live wallet status management +- ✅ Better wallet compatibility and scalability + +### Long-term Benefits + +- ✅ Improved security and user experience +- ✅ Easier future maintenance and upgrades +- ✅ Better integration with Stellar ecosystem +- ✅ Enhanced developer experience + +## ⚠️ Breaking Changes + +This implementation will impact: + +- **Components relying on existing wallet context** - New API structure +- **Network-dependent logic** - Updated network handling +- **Hardcoded connection logic** - Replaced with Stellar Wallets Kit +- **Error handling patterns** - Enhanced error management + +## 🔧 Migration Guide + +### For Existing Components + +```typescript +// Old usage +const { publicKey, connectWallet } = useWalletStore(); + +// New usage +const { publicKey, connectWallet, selectedWallet, availableWallets } = + useWalletStore(); +``` + +### For Network Switching + +```typescript +// Old: Manual network handling +// New: Built-in network switching +const { switchNetwork, network } = useWalletStore(); +await switchNetwork('public'); +``` + +## 📚 Additional Resources + +- [Stellar Wallets Kit Documentation](https://github.com/Creit-Tech/stellar-wallets-kit) +- [WalletConnect Documentation](https://docs.walletconnect.com) +- [Stellar Protocol Documentation](https://developers.stellar.org/docs) +- [Soroban Documentation](https://soroban.stellar.org/docs) + +## 🏷️ Labels + +- `enhancement` +- `wallet-integration` +- `stellar` +- `high-priority` +- `breaking-change` + +## 👥 Assignees + +- [ ] Frontend Developer +- [ ] QA Tester +- [ ] Technical Writer + +--- + +**Note:** This implementation treats the Stellar Wallets Kit as a new feature rather than an enhancement, as it replaces the current Freighter-only implementation with a comprehensive multi-wallet solution. diff --git a/app/auth/signin/page.tsx b/app/auth/signin/page.tsx index 7bc831e97..db96a61eb 100644 --- a/app/auth/signin/page.tsx +++ b/app/auth/signin/page.tsx @@ -2,7 +2,7 @@ import { useState } from 'react'; import { signIn, getSession } from 'next-auth/react'; -import { useRouter } from 'next/navigation'; +import { useRouter, useSearchParams } from 'next/navigation'; import Link from 'next/link'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; @@ -26,7 +26,8 @@ export default function SignInPage() { const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(''); const router = useRouter(); - + const searchParams = useSearchParams(); + const callbackUrl = searchParams.get('callbackUrl') || '/user'; const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); setIsLoading(true); @@ -59,7 +60,7 @@ export default function SignInPage() { Cookies.set('accessToken', session?.user.accessToken ?? ''); Cookies.set('refreshToken', session?.user.refreshToken ?? ''); } - router.push('/user'); + router.push(callbackUrl); } } } catch { diff --git a/app/debug/page.tsx b/app/debug/page.tsx deleted file mode 100644 index 7b8809b5e..000000000 --- a/app/debug/page.tsx +++ /dev/null @@ -1,270 +0,0 @@ -'use client'; - -import { useState } from 'react'; -import { Button } from '@/components/ui/button'; -import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from '@/components/ui/card'; -import { Input } from '@/components/ui/input'; -import { Label } from '@/components/ui/label'; -import { Badge } from '@/components/ui/badge'; -import { Loader2, CheckCircle, XCircle, AlertCircle } from 'lucide-react'; - -interface TestResult { - success: boolean; - error?: string; - data?: unknown; -} - -export default function DebugPage() { - const [testResult, setTestResult] = useState(null); - const [isLoading, setIsLoading] = useState(false); - const [testEmail, setTestEmail] = useState(''); - const [testPassword, setTestPassword] = useState(''); - - const testBackendConnection = async () => { - setIsLoading(true); - try { - const response = await fetch('/api/auth/test'); - const data = await response.json(); - setTestResult(data); - } catch (error) { - setTestResult({ - success: false, - error: error instanceof Error ? error.message : 'Unknown error', - }); - } finally { - setIsLoading(false); - } - }; - - const testLogin = async () => { - if (!testEmail || !testPassword) { - alert('Please enter both email and password'); - return; - } - - setIsLoading(true); - try { - const response = await fetch('/api/auth/test', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ email: testEmail, password: testPassword }), - }); - const data = await response.json(); - setTestResult(data); - } catch (error) { - setTestResult({ - success: false, - error: error instanceof Error ? error.message : 'Unknown error', - }); - } finally { - setIsLoading(false); - } - }; - - const envVars = { - NEXTAUTH_URL: process.env.NEXT_PUBLIC_NEXTAUTH_URL, - NEXTAUTH_SECRET: process.env.NEXTAUTH_SECRET ? 'Set' : 'Missing', - GOOGLE_CLIENT_ID: process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID, - GOOGLE_CLIENT_SECRET: process.env.GOOGLE_CLIENT_SECRET ? 'Set' : 'Missing', - NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL, - API_BASE_URL: process.env.API_BASE_URL || 'Not set', - }; - - return ( -
-
-
-

- Debug & Test Page -

-

- Test your authentication system and API connectivity -

-
- -
- {/* Environment Variables */} - - - Environment Variables - - Check if all required variables are set - - - -
- {Object.entries(envVars).map(([key, value]) => ( -
- {key}: - - {value || 'Missing'} - -
- ))} -
-
-
- - {/* API Connection Test */} - - - Backend Connection Test - - Test if your backend API is accessible - - - - - - - - {/* Login Test */} - - - Login Test - - Test login with your backend API - - - -
- - setTestEmail(e.target.value)} - placeholder='test@example.com' - /> -
-
- - setTestPassword(e.target.value)} - placeholder='Enter password' - /> -
- -
-
- - {/* Test Results */} - - - Test Results - Results from your tests - - - {testResult ? ( -
-
- {testResult.success ? ( - - ) : ( - - )} - - {testResult.success ? 'Success' : 'Failed'} - -
-
-                    {JSON.stringify(testResult, null, 2)}
-                  
-
- ) : ( -
- - No test results yet -
- )} -
-
-
- - {/* Instructions */} - - - Debugging Instructions - - -
-
-

- Fix Environment Variables: -

-
    -
  • - Fix NEXT_PUBLIC_API_URL: Change from{' '} - http:localhost:8000/api to{' '} - http://localhost:8000/api -
  • -
  • - Add GOOGLE_CLIENT_SECRET from Google Cloud - Console -
  • -
  • - Add API_BASE_URL=http://localhost:8000/api -
  • -
-
-
-

Test Steps:

-
    -
  1. First, test the backend connection
  2. -
  3. - If that fails, check your backend is running on port 8000 -
  4. -
  5. Test login with valid credentials
  6. -
  7. Check the console logs for detailed error messages
  8. -
-
-
-
-
-
-
- ); -} diff --git a/app/layout.tsx b/app/layout.tsx index bc48afc4c..240a4608a 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -25,7 +25,7 @@ export default function RootLayout({ children: React.ReactNode; }>) { return ( - + diff --git a/app/page.tsx b/app/page.tsx index 3bb70304a..ed1b2cc14 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,3 +1,4 @@ +'use client'; import { PriceDisplay } from '@/components/PriceDisplay'; import EmptyState from '@/components/EmptyState'; import { BoundlessButton } from '@/components/buttons'; @@ -7,83 +8,123 @@ import RecentProjects from '@/components/overview/RecentProjects'; import RecentContributions from '@/components/overview/ReecntContributions'; import GrantHistory from '@/components/overview/GrantHistory'; import { AuthNav } from '@/components/auth/AuthNav'; +import { motion } from 'framer-motion'; +import { + fadeInUp, + staggerContainer, + slideInFromLeft, + slideInFromRight, +} from '@/lib/motion'; +import PageTransition from '@/components/PageTransition'; export default function Home() { return ( -
-
-

Boundless Project

- -
-
-
- - } - iconPosition='right' - > - Add comment - - } - /> -
-
- - - -
- } - /> - - - No recent submissions -
- } - /> - - 0 - Approved Submissions - - } - /> - } - bottomText={ -
- 6 grants available -
- } - /> - - -
- - -
- - + + + +

Boundless Project

+ +
+ + + + } + iconPosition='right' + > + Add comment + + } + /> + + + + + + + + } + /> + + + + + No recent submissions + + } + /> + + + + 0 + Approved Submissions + + } + /> + + + + } + bottomText={ +
+ 6 grants available +
+ } + /> +
+
+ + + + + + + + + + + +
+
+
); } diff --git a/app/providers.tsx b/app/providers.tsx index f53191764..a35e2c5a9 100644 --- a/app/providers.tsx +++ b/app/providers.tsx @@ -2,11 +2,16 @@ import { SessionProvider } from 'next-auth/react'; import { ReactNode } from 'react'; +import { AuthProvider } from '@/components/providers/auth-provider'; interface ProvidersProps { children: ReactNode; } export function Providers({ children }: ProvidersProps) { - return {children}; + return ( + + {children} + + ); } diff --git a/app/test-auth/page.tsx b/app/test-auth/page.tsx deleted file mode 100644 index 3da8eab14..000000000 --- a/app/test-auth/page.tsx +++ /dev/null @@ -1,196 +0,0 @@ -'use client'; - -import { useSession } from 'next-auth/react'; -import Link from 'next/link'; -import { Button } from '@/components/ui/button'; -import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from '@/components/ui/card'; -import { Badge } from '@/components/ui/badge'; -import { Loader2, CheckCircle, XCircle } from 'lucide-react'; - -export default function TestAuthPage() { - const { data: session, status } = useSession(); - - const isAuthenticated = status === 'authenticated'; - const isLoading = status === 'loading'; - - return ( -
-
-
-

Auth System Test

-

- This page tests the authentication system functionality -

-
- -
- {/* Status Card */} - - - Authentication Status - Current authentication state - - -
- {isLoading ? ( - <> - - Loading... - - ) : isAuthenticated ? ( - <> - - Authenticated - Success - - ) : ( - <> - - Not Authenticated - Failed - - )} -
-
-
- - {/* Session Data */} - {isAuthenticated && session && ( - - - Session Data - - User information from the session - - - -
-
- User ID: - {session.user.id} -
-
- Email: - {session.user.email} -
-
- Name: - {session.user.name || 'Not provided'} -
-
- Role: - - {session.user.role} - -
-
- Has Access Token: - {session.user.accessToken ? 'Yes' : 'No'} -
-
-
-
- )} - - {/* Test Actions */} - - - Test Actions - - Test authentication functionality - - - -
-
- - -
-
- - -
-
-
-
- - {/* Environment Check */} - - - Environment Check - - Verify required environment variables - - - -
-
- NEXTAUTH_URL: - - {process.env.NEXT_PUBLIC_NEXTAUTH_URL ? 'Set' : 'Missing'} - -
-
- NEXTAUTH_SECRET: - - {process.env.NEXTAUTH_SECRET ? 'Set' : 'Missing'} - -
-
- GOOGLE_CLIENT_ID: - - {process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID - ? 'Set' - : 'Missing'} - -
-
- API_BASE_URL: - - {process.env.NEXT_PUBLIC_API_URL ? 'Set' : 'Missing'} - -
-
-
-
-
-
-
- ); -} diff --git a/app/user/page.tsx b/app/user/page.tsx index 7a6b97f34..ac3b8c4bc 100644 --- a/app/user/page.tsx +++ b/app/user/page.tsx @@ -8,80 +8,85 @@ import RecentContributions from '@/components/overview/ReecntContributions'; import GrantHistory from '@/components/overview/GrantHistory'; import { BoundlessButton } from '@/components/buttons'; import { getMe } from '@/lib/api/auth'; +import PageTransition from '@/components/PageTransition'; export default function UserPage() { return ( -
-
- {/* Header Section */} -
-

- Hello, Collins -

-
- getMe()}>Get user data - {/* Stats Cards Grid */} -
- - - - No recent submissions - -
- } - /> - - 0 - - Approved Submissions - -
- } - /> - } - bottomText={ -
- 6 grants available -
- } - /> - - - -
- } - /> - + +
+
+ {/* Header Section */} +
+

+ Hello, Collins +

+
+ getMe()}> + Get user data + + {/* Stats Cards Grid */} +
+ + + + No recent submissions + +
+ } + /> + + 0 + + Approved Submissions + +
+ } + /> + } + bottomText={ +
+ 6 grants available +
+ } + /> + + + +
+ } + /> + - {/* Main Content Grid */} -
- {/* Recent Projects - Full Width */} - + {/* Main Content Grid */} +
+ {/* Recent Projects - Full Width */} + - {/* Recent Contributions and Grant History - Side by Side on larger screens */} -
- - + {/* Recent Contributions and Grant History - Side by Side on larger screens */} +
+ + +
- +
); } diff --git a/app/user/projects/page.tsx b/app/user/projects/page.tsx new file mode 100644 index 000000000..bf1c8893b --- /dev/null +++ b/app/user/projects/page.tsx @@ -0,0 +1,21 @@ +'use client'; + +import AnimatedCounter from '@/components/AnimatedCounter'; +import LoadingSpinner from '@/components/LoadingSpinner'; +import LoadingSpinnerDemo from '@/components/LoadingSpinnerDemo'; +import PageTransition from '@/components/PageTransition'; +import React from 'react'; + +const page = () => { + return ( + +
+ + +
+ +
+ ); +}; + +export default page; diff --git a/auth.ts b/auth.ts index 12ca37b08..6def1e8a3 100644 --- a/auth.ts +++ b/auth.ts @@ -2,6 +2,7 @@ import NextAuth from 'next-auth'; import Credentials from 'next-auth/providers/credentials'; import { login, getMe as getMeBase } from '@/lib/api/auth'; import Google from 'next-auth/providers/google'; +import { User } from '@/lib/api/types'; function safeRole(val: unknown): 'USER' | 'ADMIN' { return val === 'ADMIN' ? 'ADMIN' : 'USER'; @@ -13,7 +14,7 @@ function getId(val1: unknown, val2: unknown): string { return 'unknown'; } -function extractUserInfo(user: any) { +function extractUserInfo(user: User) { let role: 'USER' | 'ADMIN' = 'USER'; if ( Array.isArray(user.roles) && @@ -124,22 +125,22 @@ export const { handlers, signIn, signOut, auth } = NextAuth({ const response = await login({ email, password }); if (response && response.accessToken) { - // const user = await getMe(response.accessToken); - // if (user) { - // if (user.isVerified === false) { - // throw new Error('UNVERIFIED_EMAIL'); - // } - - // const userInfo = extractUserInfo(user); - // Cookies.set('accessToken', response.accessToken); - // Cookies.set('refreshToken', response.refreshToken || ''); - - return { - // ...userInfo, - accessToken: response.accessToken, - refreshToken: response.refreshToken, - }; - // } + const user = await getMe(response.accessToken); + if (user) { + if (user.isVerified === false) { + throw new Error('UNVERIFIED_EMAIL'); + } + + const userInfo = extractUserInfo(user); + // Cookies.set('accessToken', response.accessToken); + // Cookies.set('refreshToken', response.refreshToken || ''); + + return { + ...userInfo, + accessToken: response.accessToken, + refreshToken: response.refreshToken, + }; + } } return null; } catch (err) { diff --git a/components/AnimatedCounter.tsx b/components/AnimatedCounter.tsx new file mode 100644 index 000000000..307dd5f5c --- /dev/null +++ b/components/AnimatedCounter.tsx @@ -0,0 +1,26 @@ +import { motion, useMotionValue, useTransform, animate } from 'framer-motion'; +import { useEffect } from 'react'; + +interface AnimatedCounterProps { + value: number; + className?: string; + duration?: number; +} + +const AnimatedCounter = ({ + value, + className = '', + duration = 1, +}: AnimatedCounterProps) => { + const count = useMotionValue(0); + const rounded = useTransform(count, latest => Math.round(latest)); + + useEffect(() => { + const controls = animate(count, value, { duration }); + return controls.stop; + }, [value, count, duration]); + + return {rounded}; +}; + +export default AnimatedCounter; diff --git a/components/EmptyState.tsx b/components/EmptyState.tsx index f7c5c520c..3dd0e66b0 100644 --- a/components/EmptyState.tsx +++ b/components/EmptyState.tsx @@ -1,5 +1,8 @@ +'use client'; import React from 'react'; import Image from 'next/image'; +import { motion } from 'framer-motion'; +import { fadeInUp, scaleIn } from '@/lib/motion'; interface EmptyStateProps { title: string; @@ -15,31 +18,44 @@ const EmptyState = ({ type = 'default', }: EmptyStateProps) => { return ( -
+
{type === 'default' && ( - Empty State + + Empty State + )} {type === 'comment' && ( - Empty State + + Empty State + )} -
+

{title}

{description}

-
+
- {action &&
{action}
} -
+ {action && ( + + {action} + + )} + ); }; diff --git a/components/LoadingSpinner.tsx b/components/LoadingSpinner.tsx new file mode 100644 index 000000000..da27a4bcf --- /dev/null +++ b/components/LoadingSpinner.tsx @@ -0,0 +1,141 @@ +'use client'; +import { motion } from 'framer-motion'; +import { cn } from '@/lib/utils'; + +interface LoadingSpinnerProps { + size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl'; + className?: string; + color?: 'default' | 'primary' | 'white' | 'muted'; + speed?: 'slow' | 'normal' | 'fast'; + variant?: 'dots' | 'spinner' | 'pulse'; +} + +const LoadingSpinner = ({ + size = 'md', + className = '', + color = 'default', + speed = 'normal', + variant = 'spinner', +}: LoadingSpinnerProps) => { + const sizeClasses = { + xs: 'w-3 h-3', + sm: 'w-4 h-4', + md: 'w-6 h-6', + lg: 'w-8 h-8', + xl: 'w-12 h-12', + }; + + const colorClasses = { + default: 'text-foreground', + primary: 'text-primary', + white: 'text-white', + muted: 'text-muted-foreground', + }; + + const speedClasses = { + slow: 2, + normal: 1, + fast: 0.5, + }; + + // Dots variant + if (variant === 'dots') { + return ( +
+ {[0, 1, 2].map(index => ( + + ))} +
+ ); + } + + // Pulse variant + if (variant === 'pulse') { + return ( + + ); + } + + // Default spinner variant + return ( + + + + + + + ); +}; + +export default LoadingSpinner; diff --git a/components/LoadingSpinnerDemo.tsx b/components/LoadingSpinnerDemo.tsx new file mode 100644 index 000000000..853d69545 --- /dev/null +++ b/components/LoadingSpinnerDemo.tsx @@ -0,0 +1,141 @@ +'use client'; +import LoadingSpinner from './LoadingSpinner'; + +const LoadingSpinnerDemo = () => { + return ( +
+

+ LoadingSpinner Variants +

+ + {/* Spinner Variants */} +
+

Spinner Variants

+
+
+ + Default +
+
+ + Primary +
+
+ + White +
+
+ + Muted +
+
+
+ + {/* Size Variants */} +
+

Size Variants

+
+
+ + XS +
+
+ + SM +
+
+ + MD +
+
+ + LG +
+
+ + XL +
+
+
+ + {/* Speed Variants */} +
+

Speed Variants

+
+
+ + Slow +
+
+ + Normal +
+
+ + Fast +
+
+
+ + {/* Animation Variants */} +
+

Animation Variants

+
+
+ + Spinner +
+
+ + Dots +
+
+ + Pulse +
+
+
+ + {/* Usage Examples */} +
+

Usage Examples

+
+
+

+ Button Loading State +

+
+ + Loading... +
+
+
+

+ Page Loading +

+
+ +
+
+
+
+
+ ); +}; + +export default LoadingSpinnerDemo; diff --git a/components/PageTransition.tsx b/components/PageTransition.tsx new file mode 100644 index 000000000..46cd535dd --- /dev/null +++ b/components/PageTransition.tsx @@ -0,0 +1,24 @@ +import { motion } from 'framer-motion'; +import { ReactNode } from 'react'; +import { pageTransition } from '@/lib/motion'; + +interface PageTransitionProps { + children: ReactNode; + className?: string; +} + +const PageTransition = ({ children, className }: PageTransitionProps) => { + return ( + + {children} + + ); +}; + +export default PageTransition; diff --git a/components/auth/login-form.tsx b/components/auth/login-form.tsx new file mode 100644 index 000000000..926cad4ac --- /dev/null +++ b/components/auth/login-form.tsx @@ -0,0 +1,100 @@ +'use client'; + +import React, { useState } from 'react'; +import { useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { z } from 'zod'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { useAuthActions, useAuthErrorHandler } from '@/hooks/use-auth'; +import { login } from '@/lib/api/auth'; +import { toast } from 'sonner'; + +const loginSchema = z.object({ + email: z.string().email('Please enter a valid email'), + password: z.string().min(6, 'Password must be at least 6 characters'), +}); + +type LoginFormData = z.infer; + +interface LoginFormProps { + onSuccess?: () => void; + onError?: (error: string) => void; +} + +export function LoginForm({ onSuccess, onError }: LoginFormProps) { + const [isLoading, setIsLoading] = useState(false); + const { setError } = useAuthActions(); + const { handleAuthError } = useAuthErrorHandler(); + + const { + register, + handleSubmit, + formState: { errors }, + } = useForm({ + resolver: zodResolver(loginSchema), + }); + + const onSubmit = async (data: LoginFormData) => { + try { + setIsLoading(true); + setError(null); + + await login(data); + + toast.success('Login successful!'); + onSuccess?.(); + } catch (error: unknown) { + if (handleAuthError(error as { status?: number; code?: string })) { + return; + } + + const errorMessage = + error instanceof Error + ? error.message + : 'Login failed. Please try again.'; + setError(errorMessage); + onError?.(errorMessage); + toast.error(errorMessage); + } finally { + setIsLoading(false); + } + }; + + return ( +
+
+ + + {errors.email && ( +

{errors.email.message}

+ )} +
+ +
+ + + {errors.password && ( +

{errors.password.message}

+ )} +
+ + +
+ ); +} diff --git a/components/auth/protected-route.tsx b/components/auth/protected-route.tsx new file mode 100644 index 000000000..d5a001bb6 --- /dev/null +++ b/components/auth/protected-route.tsx @@ -0,0 +1,160 @@ +'use client'; + +import React, { useEffect } from 'react'; +import { useAuthStatus } from '@/hooks/use-auth'; +import { useRouter } from 'next/navigation'; + +interface ProtectedRouteProps { + children: React.ReactNode; + fallback?: React.ReactNode; + redirectTo?: string; + requireAuth?: boolean; + requireVerified?: boolean; + requireRole?: 'USER' | 'ADMIN'; +} + +export function ProtectedRoute({ + children, + fallback, + redirectTo = '/auth/signin', + requireAuth = true, + requireVerified = false, + requireRole, +}: ProtectedRouteProps) { + const { isAuthenticated, isLoading, user } = useAuthStatus(); + const router = useRouter(); + + useEffect(() => { + if (isLoading) return; + + // Check if authentication is required and user is not authenticated + if (requireAuth && !isAuthenticated) { + router.push(redirectTo); + return; + } + + // Check if user verification is required + if (requireVerified && user && !user.isVerified) { + router.push('/auth/verify-email'); + return; + } + + // Check if specific role is required + if (requireRole && user && user.role !== requireRole) { + router.push('/unauthorized'); + return; + } + }, [ + isAuthenticated, + isLoading, + user, + requireAuth, + requireVerified, + requireRole, + router, + redirectTo, + ]); + + // Show loading state + if (isLoading) { + return ( + fallback || ( +
+
+
+ ) + ); + } + + // Show fallback if not authenticated and auth is required + if (requireAuth && !isAuthenticated) { + return ( + fallback || ( +
+
+
+ ) + ); + } + + // Show fallback if user is not verified + if (requireVerified && user && !user.isVerified) { + return ( + fallback || ( +
+
+

+ Email Verification Required +

+

+ Please verify your email address to continue. +

+
+
+ ) + ); + } + + // Show fallback if user doesn't have required role + if (requireRole && user && user.role !== requireRole) { + return ( + fallback || ( +
+
+

Access Denied

+

+ You don't have permission to access this page. +

+
+
+ ) + ); + } + + return <>{children}; +} + +// Convenience components for different protection levels +export function RequireAuth({ + children, + ...props +}: Omit) { + return ( + + {children} + + ); +} + +export function RequireVerified({ + children, + ...props +}: Omit) { + return ( + + {children} + + ); +} + +export function RequireAdmin({ + children, + ...props +}: Omit) { + return ( + + {children} + + ); +} + +export function RequireUser({ + children, + ...props +}: Omit) { + return ( + + {children} + + ); +} diff --git a/components/buttons/BoundlessButton.tsx b/components/buttons/BoundlessButton.tsx index 886d0b677..9c13b6c69 100644 --- a/components/buttons/BoundlessButton.tsx +++ b/components/buttons/BoundlessButton.tsx @@ -1,7 +1,10 @@ +'use client'; import * as React from 'react'; import { Button } from '@/components/ui/button'; import { cva, type VariantProps } from 'class-variance-authority'; import { cn } from '@/lib/utils'; +import { motion } from 'framer-motion'; +import { buttonHover } from '@/lib/motion'; const boundlessButtonVariants = cva( "inline-flex items-center justify-center gap-2 whitespace-nowrap !rounded-[10px] text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", @@ -116,21 +119,28 @@ const BoundlessButton = React.forwardRef< ); return ( - + +
); } ); diff --git a/components/card.tsx b/components/card.tsx index 621ad9b52..b22c2f1fa 100644 --- a/components/card.tsx +++ b/components/card.tsx @@ -1,6 +1,9 @@ +'use client'; import { Button } from '@/components/ui/button'; import { ChevronRight } from 'lucide-react'; import React from 'react'; +import { motion } from 'framer-motion'; +import { cardHover, iconSpin } from '@/lib/motion'; const Card = ({ title, @@ -12,14 +15,24 @@ const Card = ({ bottomText: React.ReactNode; }) => { return ( -
+

{title}

- + + +
{value} @@ -29,7 +42,7 @@ const Card = ({ {bottomText}
)} - + ); }; diff --git a/components/connect-wallet/index.tsx b/components/connect-wallet/index.tsx new file mode 100644 index 000000000..a97bf49bb --- /dev/null +++ b/components/connect-wallet/index.tsx @@ -0,0 +1,378 @@ +import React, { useState, useEffect } from 'react'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogTitle, +} from '../ui/dialog'; +import WalletCard from './wallet-card'; +import { CircleQuestionMark, X, Loader2, AlertCircle } from 'lucide-react'; +import Link from 'next/link'; +import { Checkbox } from '../ui/checkbox'; +import { ScrollArea } from '../ui/scroll-area'; +import { Button } from '../ui/button'; +import { useWalletStore } from '@/hooks/use-wallet'; +import { toast } from 'sonner'; + +const ConnectWallet = ({ + open, + onOpenChange, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; +}) => { + const [selectedNetwork, setSelectedNetwork] = useState('testnet'); + const [acceptedTerms, setAcceptedTerms] = useState(true); + const [isConnecting, setIsConnecting] = useState(false); + const [connectingWallet, setConnectingWallet] = useState(null); + + const { + network, + availableWallets, + isConnected, + isLoading, + error, + initializeWalletKit, + connectWallet, + clearError, + } = useWalletStore(); + + // Initialize wallet kit when modal opens + useEffect(() => { + if (open && !isConnected) { + initializeWalletKit(selectedNetwork as 'testnet' | 'public').catch(() => { + // Silently handle initialization errors + // These are expected when wallet is not available + }); + } + }, [open, isConnected, initializeWalletKit, selectedNetwork]); + + // Handle network selection + useEffect(() => { + setSelectedNetwork(network); + }, [network]); + + const networks = [ + { + id: 'testnet', + name: 'Testnet', + icon: '/globe.svg', + active: true, + }, + { + id: 'public', + name: 'Public', + icon: '/globe.svg', + active: true, + }, + ]; + + const handleWalletSelect = async (walletId: string) => { + if (!acceptedTerms) { + toast.error('Please accept the terms and conditions first'); + return; + } + + setIsConnecting(true); + setConnectingWallet(walletId); + clearError(); + + try { + // Show specific instructions for different wallets + const walletInstructions = { + freighter: 'Please unlock Freighter and approve the connection', + albedo: + 'Albedo will open in a new window. Please approve the connection', + rabet: 'Please unlock Rabet and approve the connection', + xbull: 'Please unlock xBull and approve the connection', + lobstr: 'Please unlock Lobstr and approve the connection', + hana: 'Please unlock Hana and approve the connection', + 'hot-wallet': + 'Please unlock your hardware wallet and approve the connection', + }; + + const instruction = + walletInstructions[walletId as keyof typeof walletInstructions] || + 'Please approve the connection'; + + toast.info(instruction, { + duration: 5000, + }); + + await connectWallet(walletId); + + toast.success('Wallet connected successfully!', { + description: `Connected to ${network === 'testnet' ? 'Testnet' : 'Public'} network`, + }); + onOpenChange(false); + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : 'Failed to connect wallet'; + + // Provide specific error messages for different wallets + let specificError = errorMessage; + if (errorMessage.includes('not available')) { + specificError = `${walletId} is not installed or not available. Please install it first.`; + } else if (errorMessage.includes('permission')) { + specificError = `Permission denied. Please unlock ${walletId} and try again.`; + } else if (errorMessage.includes('network')) { + specificError = `Network mismatch. Please switch ${walletId} to ${selectedNetwork} network.`; + } + + toast.error('Connection failed', { + description: specificError, + duration: 8000, + }); + } finally { + setIsConnecting(false); + setConnectingWallet(null); + } + }; + + const handleNetworkChange = async (networkId: string) => { + if (networkId === selectedNetwork) return; + + setSelectedNetwork(networkId); + + // Reinitialize wallet kit with new network + try { + await initializeWalletKit(networkId as 'testnet' | 'public'); + toast.success( + `Switched to ${networkId === 'testnet' ? 'Testnet' : 'Public'} network` + ); + } catch { + toast.error('Failed to switch network'); + // Log error for debugging but don't expose to user + } + }; + + // Filter available wallets and map them to our UI format + const walletOptions = availableWallets + .filter(wallet => wallet.isAvailable) + .map(wallet => ({ + id: wallet.id, + name: wallet.name, + icon: wallet.icon, + disabled: false, + })); + + // Fallback wallets if none are available + const fallbackWallets = [ + { + id: 'freighter', + name: 'Freighter', + icon: '/wallets/freighter.svg', + disabled: false, + }, + { + id: 'albedo', + name: 'Albedo', + icon: '/wallets/albedo.svg', + disabled: false, + }, + { + id: 'rabet', + name: 'Rabet', + icon: '/wallets/rabet.svg', + disabled: false, + }, + { + id: 'xbull', + name: 'xBull', + icon: '/wallets/xbull.svg', + disabled: false, + }, + { + id: 'lobstr', + name: 'Lobstr', + icon: '/wallets/lobstr.svg', + disabled: false, + }, + { + id: 'hana', + name: 'Hana', + icon: '/wallets/hana.svg', + disabled: false, + }, + { + id: 'hot-wallet', + name: 'HOT Wallet', + icon: '/wallets/hot-wallet.svg', + disabled: false, + }, + ]; + + const wallets = walletOptions.length > 0 ? walletOptions : fallbackWallets; + + return ( + + + {/* Header */} +
+
+ + Connect Wallet + + +
+ +
+ + {/* Description and Terms */} + +

+ Select what network and wallet below +

+ +
+

+ Accept{' '} + + Terms of Service + {' '} + and{' '} + + Privacy Policy + +

+
+ + setAcceptedTerms(checked as boolean) + } + className='data-[state=checked]:bg-[#A7F950] data-[state=checked]:border-[#A7F950]' + id='terms-checkbox' + /> + +
+
+
+ + {/* Network Selection */} +
+

Choose Network

+
+ {networks.map(network => ( + + ))} +
+
+ + {/* Wallet Selection */} +
+
+

Choose Wallet

+ {isLoading && ( +
+ + Loading wallets... +
+ )} +
+ + {/* Connection Status */} + {isConnecting && connectingWallet && ( +
+ + + Connecting to {connectingWallet}... + +
+ )} + + +
+ {wallets.map(wallet => ( + handleWalletSelect(wallet.id)} + icon={wallet.icon} + label={wallet.name} + /> + ))} +
+
+
+ + {/* Error Display */} + {error && ( +
+
+ + + Connection Error + +
+

{error}

+
+ )} + + {/* Help Text */} +
+

+ Freighter: Browser extension wallet +

+

+ Albedo: Web-based wallet (opens in new window) +

+

+ Rabet: Browser extension wallet +

+

+ xBull: Mobile and web wallet +

+

+ Lobstr: Mobile wallet +

+

+ Hana: Mobile wallet +

+

+ HOT Wallet: Hardware wallet interface +

+
+
+
+ ); +}; + +export default ConnectWallet; diff --git a/components/connect-wallet/wallet-card.tsx b/components/connect-wallet/wallet-card.tsx new file mode 100644 index 000000000..adffb9645 --- /dev/null +++ b/components/connect-wallet/wallet-card.tsx @@ -0,0 +1,42 @@ +import Image from 'next/image'; +import React from 'react'; + +interface WalletCardProps { + disabled: boolean; + onClick: () => void; + icon: string; + label: string; +} + +const WalletCard = ({ disabled, onClick, icon, label }: WalletCardProps) => { + return ( + + ); +}; + +export default WalletCard; diff --git a/components/layout/DashboardInset.tsx b/components/layout/DashboardInset.tsx index 5ad8e0867..0568d75e8 100644 --- a/components/layout/DashboardInset.tsx +++ b/components/layout/DashboardInset.tsx @@ -3,24 +3,35 @@ import { ReactNode } from 'react'; import { SidebarInset, useSidebar } from '@/components/ui/sidebar'; import { ScrollArea } from '@/components/ui/scroll-area'; import Header from './header'; +import { motion } from 'framer-motion'; +import { fadeInUp } from '@/lib/motion'; export default function DashboardInset({ children }: { children: ReactNode }) { const { state } = useSidebar(); return ( -
-
{children}
+ +
{children}
+
-
+
); } diff --git a/components/layout/header.tsx b/components/layout/header.tsx index 1d2c99af5..992fb7c69 100644 --- a/components/layout/header.tsx +++ b/components/layout/header.tsx @@ -7,52 +7,97 @@ import { Plus, Search, Menu } from 'lucide-react'; import { SidebarTrigger } from '../ui/sidebar'; import Image from 'next/image'; import BoundlessSheet from '../sheet/boundless-sheet'; +import { motion } from 'framer-motion'; +import { fadeInUp, slideInFromLeft, slideInFromRight } from '@/lib/motion'; import WalletConnectButton from '../wallet/WalletConnectButton'; const Header = () => { const [open, setOpen] = useState(false); + return ( -
+ {/* Mobile Menu Trigger - Only visible on mobile */} -
- - - - logo -
+ + + + + + + + logo + + {/* Search Bar */} -
+
- + - - + + + +
-
+ {/* Action Buttons */} -
+ {/* New Project Button */} - } - iconPosition='right' - onClick={() => setOpen(true)} - > - New Project - + + } + iconPosition='right' + onClick={() => setOpen(true)} + > + New Project + + + + {/* Wallet Connect Button */} + + + + - {/* Connect Wallet Button */} - -
-
+ ); }; diff --git a/components/layout/navbar.tsx b/components/layout/navbar.tsx index e69de29bb..5208b0ce4 100644 --- a/components/layout/navbar.tsx +++ b/components/layout/navbar.tsx @@ -0,0 +1,61 @@ +'use client'; +import React from 'react'; +import { motion } from 'framer-motion'; +import { fadeInUp, slideInFromLeft, slideInFromRight } from '@/lib/motion'; +import { Search, Bell, User } from 'lucide-react'; +import { Input } from '@/components/ui/input'; +import { Button } from '@/components/ui/button'; +import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; + +const Navbar = () => { + return ( + +
+ {/* Left side - Search */} + +
+ + +
+
+ + {/* Right side - Actions */} + + {/* Notifications */} + + + + + {/* User Avatar */} + + + + + + + + + +
+
+ ); +}; + +export default Navbar; diff --git a/components/layout/sidebar.tsx b/components/layout/sidebar.tsx index a5145a317..8701ea58d 100644 --- a/components/layout/sidebar.tsx +++ b/components/layout/sidebar.tsx @@ -28,37 +28,42 @@ import { SidebarTrigger, } from '@/components/ui/sidebar'; import Image from 'next/image'; - -interface SidebarLayoutProps { - activeSection?: string; - onSectionChange?: (section: string) => void; -} +import { useRouter, usePathname } from 'next/navigation'; +import { motion } from 'framer-motion'; +import { fadeInUp, staggerContainer } from '@/lib/motion'; +import { useAuth } from '@/hooks/use-auth'; +import { Button } from '../ui/button'; const navigationItems = [ { id: 'overview', label: 'Overview', icon: LayoutDashboardIcon, + href: '/user', }, { id: 'projects', label: 'Projects', icon: Package, + href: '/user/projects', }, { id: 'campaigns', label: 'Campaigns', icon: Sun, + href: '/user/campaigns', }, { id: 'grants', label: 'Grants', icon: HandHeart, + href: '/user/grants', }, { id: 'activities', label: 'My Activities', icon: Activity, + href: '/user/activities', }, ]; @@ -67,139 +72,240 @@ const utilityItems = [ id: 'notifications', label: 'Notifications', icon: Bell, + href: '/user/notifications', }, { id: 'settings', label: 'Settings', icon: Settings, + href: '/user/settings', }, ]; -const SidebarLayout: React.FC = ({ - activeSection = 'overview', - onSectionChange, -}) => { +const SidebarLayout: React.FC = () => { + const router = useRouter(); + const pathname = usePathname(); + const { user } = useAuth(); + // Function to determine if a route is active + const isRouteActive = (href: string) => { + if (href === '/user') { + return pathname === '/user'; + } + return pathname.startsWith(href); + }; + return ( - -
- logo - {/* Mobile Menu Trigger - Always visible on mobile */} - - - -
- - -
- - - - - - -
-

- Collins Odumeje -

-
- + + + + logo + + {/* Mobile Menu Trigger - Always visible on mobile */} + + + + + + + + + - - - - -
-
-
-
- - - {/* Main Navigation */} - - Navigation - - - {navigationItems.map(item => { - const Icon = item.icon; - const isActive = activeSection === item.id; - - return ( - - onSectionChange?.(item.id)} - className={cn( - 'w-full flex items-center space-x-2 sm:space-x-3 px-2 sm:px-3 py-3 sm:py-5 rounded-lg text-left transition-colors overflow-hidden relative', - isActive - ? 'bg-background text-white' - : 'text-gray-400 hover:text-white hover:bg-background/50' + + + + {user?.name ? ( + + {user.name.charAt(0).toUpperCase()} + + ) : ( + )} + + + +
+

+ {user?.name || user?.email || 'User'} +

+ {user?.email && user?.name && ( +

+ {user.email} +

+ )} +
+ + + - - {isActive && ( - - )} - - {item.label} - -
-
- ); - })} -
-
-
-
+ + + + + + + - {/* Utility Navigation */} - - - - - {utilityItems.map(item => { - const Icon = item.icon; + + {/* Main Navigation */} + + + Navigation + + + + + {navigationItems.map((item, index) => { + const Icon = item.icon; + const isActive = isRouteActive(item.href); - return ( - - onSectionChange?.(item.id)} - className='w-full flex items-center space-x-2 sm:space-x-3 px-2 sm:px-3 py-2 rounded-lg text-left text-gray-400 hover:text-white hover:bg-[#2A2A2A]/50 transition-colors' - > - - - {item.label} - - - - ); - })} - - - - + return ( + + + + router.push(item.href)} + className={cn( + 'w-full flex items-center space-x-2 sm:space-x-3 px-2 sm:px-3 py-3 sm:py-5 rounded-lg text-left transition-colors overflow-hidden relative', + isActive + ? 'bg-background text-white' + : 'text-gray-400 hover:text-white hover:bg-background/50' + )} + > + + {isActive && ( + + )} + + {item.label} + + + + + + ); + })} + + + + + + + {/* Utility Navigation */} + + + + + + {utilityItems.map((item, index) => { + const Icon = item.icon; + const isActive = isRouteActive(item.href); + + return ( + + + + router.push(item.href)} + className={cn( + 'w-full flex items-center space-x-2 sm:space-x-3 px-2 sm:px-3 py-2 rounded-lg text-left transition-colors', + isActive + ? 'text-white bg-[#2A2A2A]/50' + : 'text-gray-400 hover:text-white hover:bg-[#2A2A2A]/50' + )} + > + + + {item.label} + + + + + + ); + })} + + + + + + +
); }; diff --git a/components/overview/RecentProjects.tsx b/components/overview/RecentProjects.tsx index 5736bc475..f1718ced1 100644 --- a/components/overview/RecentProjects.tsx +++ b/components/overview/RecentProjects.tsx @@ -1,32 +1,44 @@ +'use client'; import React from 'react'; import { RecentProjectsProps } from '@/types/project'; import { Plus } from 'lucide-react'; import ProjectCard from '../project-card'; import EmptyState from '../EmptyState'; import { BoundlessButton } from '../buttons'; +import { motion } from 'framer-motion'; +import { fadeInUp, staggerContainer } from '@/lib/motion'; const RecentProjects = ({ projects }: { projects: RecentProjectsProps[] }) => { return ( -
+ {/* Header */} -
+

Recent Projects

-
+
{/* Projects Grid */} -
+ {projects.length > 0 ? ( - projects.map(project => ( - + projects.map((project, index) => ( + + + )) ) : ( -
+
{ } />
-
+
)} -
-
+ + ); }; diff --git a/components/project-card.tsx b/components/project-card.tsx index d7d0cc84d..8feccb2cb 100644 --- a/components/project-card.tsx +++ b/components/project-card.tsx @@ -28,6 +28,8 @@ import { } from './ui/dropdown-menu'; import { Button } from './ui/button'; import CircularProgress from './ui/circular-progress'; +import { motion } from 'framer-motion'; +import { cardHover, fadeInUp } from '@/lib/motion'; interface ProjectCardProps { project: Project; @@ -120,9 +122,18 @@ const ProjectCard: React.FC = ({ const actionCounts = getActionCounts(project.status); return ( -
+ {/* Image Container */} -
+ = ({ /> {/* Status Badge */} - - {project.status.replace('_', ' ')} - + + {project.status.replace('_', ' ')} + + {/* Category Badge */} - - {project.category} - + + {project.category} + + {/* Ellipsis Menu - Conditional */} @@ -157,12 +180,17 @@ const ProjectCard: React.FC = ({ - - - - - - + + + + + + + + = ({ -
+
{/* Content */} -
+ {/* Title and Price */}
@@ -187,31 +215,36 @@ const ProjectCard: React.FC = ({ : project.name} {showEllipsisMenu && ( - - - + + - - - - - - Edit Project - - - View Details - - - Delete Project - - - + + Edit Project + + + View Details + + + Delete Project + + + + )}
= ({
)}
-
- + + ); }; diff --git a/components/providers/auth-provider.tsx b/components/providers/auth-provider.tsx new file mode 100644 index 000000000..cf45b47e9 --- /dev/null +++ b/components/providers/auth-provider.tsx @@ -0,0 +1,109 @@ +'use client'; + +import React, { useEffect, useState } from 'react'; +import { useAuthStore } from '@/lib/stores/auth-store'; + +interface AuthProviderProps { + children: React.ReactNode; +} + +export function AuthProvider({ children }: AuthProviderProps) { + const [isHydrated, setIsHydrated] = useState(false); + const { isAuthenticated, accessToken, refreshUser, clearAuth } = + useAuthStore(); + + // Handle store hydration + useEffect(() => { + // Check if already hydrated + if (useAuthStore.persist.hasHydrated()) { + setIsHydrated(true); + return; + } + + const unsubscribe = useAuthStore.persist.onFinishHydration(() => { + setIsHydrated(true); + }); + + // Fallback timeout to prevent infinite loading + const timeout = setTimeout(() => { + setIsHydrated(true); + }, 2000); + + return () => { + unsubscribe(); + clearTimeout(timeout); + }; + }, []); + + // Initialize auth state on mount + useEffect(() => { + if (!isHydrated) return; + + const initializeAuth = async () => { + try { + // If we have a token but no user data, try to refresh + if (accessToken && isAuthenticated) { + await refreshUser(); + } else if (accessToken) { + // Try to refresh user data to validate token + try { + await refreshUser(); + } catch { + // If refresh fails, clear auth data + clearAuth(); + } + } + } catch { + clearAuth(); + } + }; + + initializeAuth(); + }, [isHydrated, accessToken, isAuthenticated, refreshUser, clearAuth]); + + // Show loading state while hydrating + if (!isHydrated) { + return ( +
+
+
+ ); + } + + return <>{children}; +} + +// Hook to check if auth is hydrated +export function useAuthHydration() { + const [isHydrated, setIsHydrated] = useState(false); + + useEffect(() => { + const unsubscribe = useAuthStore.persist.onFinishHydration(() => { + setIsHydrated(true); + }); + + return unsubscribe; + }, []); + + return isHydrated; +} + +// Component to show loading while auth is initializing +export function AuthLoadingProvider({ + children, +}: { + children: React.ReactNode; +}) { + const isHydrated = useAuthHydration(); + const { isLoading } = useAuthStore(); + + if (!isHydrated || isLoading) { + return ( +
+
+
+ ); + } + + return <>{children}; +} diff --git a/components/wallet/WalletConnectButton.tsx b/components/wallet/WalletConnectButton.tsx index 0811f025e..9a3b20720 100644 --- a/components/wallet/WalletConnectButton.tsx +++ b/components/wallet/WalletConnectButton.tsx @@ -2,17 +2,43 @@ import React, { useState } from 'react'; import { Button } from '../ui/button'; -import { Loader2, CheckCircle, Copy } from 'lucide-react'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '../ui/dropdown-menu'; +import { + Loader2, + CheckCircle, + Copy, + Wallet, + LogOut, + Settings, + ExternalLink, + ChevronDown, +} from 'lucide-react'; import { cn, copyToClipboard } from '@/lib/utils'; import { toast } from 'sonner'; -import { setAllowed } from '@stellar/freighter-api'; import WalletErrorGuide from './WalletErrorGuide'; import { useWalletInfo, useWalletStore, useAutoReconnect, + useNetworkSwitcher, } from '@/hooks/use-wallet'; +import { + formatAddress, + getWalletDisplayName, + getExplorerUrl, + getNetworkDisplayName, + validateAddress, +} from '@/lib/wallet-utils'; import { BoundlessButton } from '../buttons'; +import ConnectWallet from '../connect-wallet'; +import { Badge } from '../ui/badge'; interface WalletConnectButtonProps { className?: string; @@ -34,17 +60,19 @@ const WalletConnectButton: React.FC = ({ showErrorGuide = true, }) => { const [showErrorGuideState, setShowErrorGuideState] = useState(false); + const [showConnectModal, setShowConnectModal] = useState(false); + const [dropdownOpen, setDropdownOpen] = useState(false); const { - network, isConnected, isLoading, error, - connectWallet, disconnectWallet, clearError, + selectedWallet, } = useWalletStore(); + const { currentNetwork, switchToNetwork } = useNetworkSwitcher(); const walletInfo = useWalletInfo(); // Auto-reconnect on page load @@ -68,41 +96,46 @@ const WalletConnectButton: React.FC = ({ } }, [error, clearError, showErrorGuide]); - const handleConnect = async () => { - try { - setShowErrorGuideState(false); + const handleConnect = () => { + setShowErrorGuideState(false); + setShowConnectModal(true); + }; - // Set allowed first (as per your code pattern) - await setAllowed(); + const handleDisconnect = () => { + disconnectWallet(); + toast.success('Wallet disconnected'); + setDropdownOpen(false); + }; - // Then connect wallet - await connectWallet(); + const handleCopyAddress = () => { + if (walletInfo) { + copyToClipboard(walletInfo.address); + toast.success('Address copied to clipboard'); + } + }; - toast.success('Wallet connected successfully!', { - description: `Connected to ${ - network === 'testnet' ? 'Testnet' : 'Public' - } network`, - }); - } catch (error) { - toast.error('Failed to connect wallet', { - description: - (error as Error)?.message || - 'Please try again or check your wallet connection', - action: { - label: 'Dismiss', - onClick: clearError, - }, - }); + const handleSwitchNetwork = async (newNetwork: 'testnet' | 'public') => { + try { + await switchToNetwork(newNetwork); + toast.success( + `Switched to ${newNetwork === 'testnet' ? 'Testnet' : 'Public'} network` + ); + } catch { + toast.error('Failed to switch network'); } }; - const handleDisconnect = () => { - disconnectWallet(); - toast.success('Wallet disconnected'); + const handleSwitchWallet = () => { + setShowConnectModal(true); + setDropdownOpen(false); }; const getNetworkDisplay = (network: string) => { - return network === 'testnet' ? 'Testnet' : 'Public'; + return getNetworkDisplayName(network as 'testnet' | 'public'); + }; + + const getWalletDisplayNameLocal = (walletId: string) => { + return getWalletDisplayName(walletId); }; if (isLoading) { @@ -122,33 +155,157 @@ const WalletConnectButton: React.FC = ({ if (isConnected && walletInfo) { return (
-
-
- + + + + + +
+ Connected Wallet +
+ + + {/* Wallet Info - Compact */} + e.preventDefault()} + onClick={handleCopyAddress} + > + +
+
+ + {getWalletDisplayNameLocal(selectedWallet || '')} + + + {getNetworkDisplay(currentNetwork)} + +
+
+
+ {validateAddress(walletInfo.address) + ? formatAddress(walletInfo.address, 8) + : walletInfo.address} +
+ +
+
+
+ + + + {/* Quick Actions */} +
+ + + Switch Wallet + +
+ + + + {/* Network Switching - Compact */} +
+
+ Network +
+
+ handleSwitchNetwork('testnet')} + className={cn( + 'bg-card hover:bg-accent text-card-foreground hover:text-accent-foreground text-xs justify-center', + currentNetwork === 'testnet' && + 'bg-accent text-accent-foreground' + )} + > +
+ Testnet + {currentNetwork === 'testnet' && ( + + )} +
+ + handleSwitchNetwork('public')} + className={cn( + 'bg-card hover:bg-accent text-card-foreground hover:text-accent-foreground text-xs justify-center', + currentNetwork === 'public' && + 'bg-accent text-accent-foreground' + )} + > +
+ Public + {currentNetwork === 'public' && ( + + )} +
+
+
+ + + + {/* External Link */} + + + + View on Stellar Expert + + + + + + {/* Disconnect */} + - - {walletInfo.displayName} -
- copyToClipboard(walletInfo.address)} - /> -
-
-
- - {getNetworkDisplay(network)} - -
-
+ + Disconnect + + + {showErrorGuideState && showErrorGuide && ( )} + +
); } @@ -161,12 +318,18 @@ const WalletConnectButton: React.FC = ({ className={cn('min-w-[140px]', className)} onClick={handleConnect} > + Connect Wallet {showErrorGuideState && showErrorGuide && ( )} + + ); }; diff --git a/docs/AUTH_SYSTEM.md b/docs/AUTH_SYSTEM.md new file mode 100644 index 000000000..5781fee8b --- /dev/null +++ b/docs/AUTH_SYSTEM.md @@ -0,0 +1,370 @@ +# Authentication System Documentation + +This document describes the comprehensive authentication system implemented using Zustand for state management, with persistence and proper error handling. + +## Overview + +The authentication system provides: + +- **Zustand Store**: Centralized state management with persistence +- **Automatic Token Refresh**: Handles 401 errors and token expiration +- **Server & Client Support**: Works in both server and client components +- **Type Safety**: Full TypeScript support +- **Error Handling**: Consistent 401 error handling across the app + +## Architecture + +### Core Components + +1. **Auth Store** (`lib/stores/auth-store.ts`) + - Manages authentication state + - Handles token persistence + - Provides login/logout actions + +2. **API Client** (`lib/api/api.ts`) + - Automatic token refresh on 401 errors + - Consistent error handling + - Request/response interceptors + +3. **Auth API** (`lib/api/auth.ts`) + - Authentication API functions + - Store integration + - Enhanced utilities + +4. **Auth Hooks** (`hooks/use-auth.ts`) + - Client-side auth utilities + - Backward compatibility with NextAuth + - Error handling hooks + +5. **Server Auth Utilities** (`lib/auth/server-auth.ts`) + - Server-side authentication + - Protected route utilities + - Cookie-based token access + +## Usage + +### Client Components + +#### Basic Authentication Hook + +```tsx +import { useAuth } from '@/hooks/use-auth'; + +function MyComponent() { + const { user, isAuthenticated, isLoading, logout } = useAuth(); + + if (isLoading) return
Loading...
; + + return ( +
+ {isAuthenticated ? ( +
+

Welcome, {user?.name}!

+ +
+ ) : ( +

Please sign in

+ )} +
+ ); +} +``` + +#### Protected Routes + +```tsx +import { ProtectedRoute } from '@/components/auth/protected-route'; + +function Dashboard() { + return ( + +
Protected Dashboard Content
+
+ ); +} + +// With role requirements +function AdminPanel() { + return ( + +
Admin Only Content
+
+ ); +} +``` + +#### Login Form + +```tsx +import { LoginForm } from '@/components/auth/login-form'; + +function SignInPage() { + return ( +
+

Sign In

+ router.push('/dashboard')} + onError={error => console.error(error)} + /> +
+ ); +} +``` + +### Server Components + +#### Server-side Authentication + +```tsx +import { getServerUser, requireServerAuth } from '@/lib/auth/server-auth'; + +// Optional auth check +async function ProfilePage() { + const user = await getServerUser(); + + if (!user) { + return
Please sign in
; + } + + return
Welcome, {user.name}!
; +} + +// Required auth check +async function ProtectedPage() { + const user = await requireServerAuth(); // Redirects to /auth/signin if not authenticated + + return
Protected content for {user.name}
; +} +``` + +#### With Server Actions + +```tsx +import { getServerAuthHeaders } from '@/lib/auth/server-auth'; + +async function updateProfile(formData: FormData) { + 'use server'; + + const headers = await getServerAuthHeaders(); + + const response = await fetch('/api/profile', { + method: 'PUT', + headers: { + ...headers, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + name: formData.get('name'), + }), + }); + + if (!response.ok) { + throw new Error('Failed to update profile'); + } + + return response.json(); +} +``` + +## API Integration + +### Making Authenticated Requests + +```tsx +import { api } from '@/lib/api/api'; + +// Automatic token handling +const response = await api.get('/api/protected-endpoint'); + +// With custom headers +const response = await api.post('/api/data', data, { + headers: { + 'Custom-Header': 'value', + }, +}); +``` + +### Error Handling + +```tsx +import { useAuthErrorHandler } from '@/hooks/use-auth'; + +function MyComponent() { + const { handleAuthError } = useAuthErrorHandler(); + + const handleApiCall = async () => { + try { + await api.get('/api/protected'); + } catch (error) { + if (handleAuthError(error)) { + // Auth error was handled (user redirected to login) + return; + } + // Handle other errors + console.error('API Error:', error); + } + }; +} +``` + +## Configuration + +### Environment Variables + +```env +NEXT_PUBLIC_API_BASE_URL=https://api.example.com +``` + +### Store Persistence + +The auth store automatically persists to localStorage and syncs with cookies: + +```tsx +// Store configuration in lib/stores/auth-store.ts +persist( + (set, get) => ({ + // ... store implementation + }), + { + name: 'auth-storage', + storage: createJSONStorage(() => localStorage), + partialize: state => ({ + user: state.user, + accessToken: state.accessToken, + refreshToken: state.refreshToken, + isAuthenticated: state.isAuthenticated, + }), + } +); +``` + +## Error Handling + +### 401 Unauthorized Errors + +The system automatically handles 401 errors: + +1. **Automatic Token Refresh**: Attempts to refresh the access token +2. **Fallback to Login**: If refresh fails, redirects to login page +3. **State Cleanup**: Clears auth state and cookies +4. **User Feedback**: Shows appropriate error messages + +### Custom Error Handling + +```tsx +import { useAuthActions } from '@/hooks/use-auth'; + +function MyComponent() { + const { setError } = useAuthActions(); + + const handleError = (error: any) => { + if (error.status === 401) { + setError('Session expired. Please login again.'); + } else { + setError(error.message); + } + }; +} +``` + +## Migration from NextAuth + +The system maintains backward compatibility with NextAuth: + +```tsx +// Old NextAuth usage still works +import { useSession } from 'next-auth/react'; + +function Component() { + const { data: session } = useSession(); + // ... existing code +} + +// New Zustand usage +import { useAuth } from '@/hooks/use-auth'; + +function Component() { + const { user, isAuthenticated } = useAuth(); + // ... new code +} +``` + +## Best Practices + +### 1. Use Protected Routes + +Always wrap protected content with `ProtectedRoute`: + +```tsx + + + +``` + +### 2. Handle Loading States + +```tsx +const { isLoading, user } = useAuth(); + +if (isLoading) { + return ; +} +``` + +### 3. Server-side Validation + +Always validate auth on the server for sensitive operations: + +```tsx +async function sensitiveAction() { + 'use server'; + + const user = await requireServerAuth(); + // Proceed with action +} +``` + +### 4. Error Boundaries + +Wrap components that make API calls in error boundaries: + +```tsx +}> + + +``` + +## Troubleshooting + +### Common Issues + +1. **Hydration Mismatch**: Ensure auth provider wraps the app +2. **Token Not Persisting**: Check localStorage and cookie settings +3. **401 Loops**: Verify refresh token endpoint is working +4. **Server/Client Mismatch**: Use appropriate auth utilities for each context + +### Debug Mode + +Enable debug logging: + +```tsx +// In development +if (process.env.NODE_ENV === 'development') { + console.log('Auth State:', useAuthStore.getState()); +} +``` + +## Security Considerations + +1. **Token Storage**: Tokens are stored in both localStorage and cookies +2. **Automatic Cleanup**: Failed auth attempts clear all stored data +3. **HTTPS Only**: Ensure cookies are secure in production +4. **Token Refresh**: Implements secure token refresh flow +5. **CSRF Protection**: Uses proper CSRF tokens where needed + +## Performance + +1. **Selective Re-renders**: Zustand only re-renders when subscribed state changes +2. **Lazy Loading**: Auth state is loaded only when needed +3. **Persistent State**: Reduces unnecessary API calls on page reload +4. **Efficient Updates**: Batch updates to minimize re-renders diff --git a/docs/FRAMER_MOTION_INTEGRATION.md b/docs/FRAMER_MOTION_INTEGRATION.md new file mode 100644 index 000000000..da5e1e711 --- /dev/null +++ b/docs/FRAMER_MOTION_INTEGRATION.md @@ -0,0 +1,194 @@ +# Framer Motion Integration + +This project has been enhanced with Framer Motion animations while preserving the existing design aesthetic. All animations are subtle and enhance the user experience without being distracting. + +## 🎨 Animation Variants + +All animation variants are defined in `lib/motion.ts` and follow a consistent design language: + +### Page Transitions + +- `pageTransition`: Smooth fade-in/fade-out with slight vertical movement +- `fadeInUp`: Elements fade in while moving up from their initial position +- `fadeIn`: Simple fade-in animation +- `scaleIn`: Elements scale up from 95% to 100% while fading in + +### Interactive Animations + +- `buttonHover`: Subtle scale effect on button hover and tap +- `cardHover`: Cards lift slightly on hover +- `iconSpin`: Icons rotate 360° on hover + +### Layout Animations + +- `staggerContainer`: Container that staggers child animations +- `slideInFromLeft`: Elements slide in from the left +- `slideInFromRight`: Elements slide in from the right + +## 🧩 Enhanced Components + +### Main Page (`app/page.tsx`) + +- Staggered entrance animations for all sections +- Header slides in from left +- Cards fade in with staggered timing +- Recent projects and contributions slide in from opposite sides + +### Card Component (`components/card.tsx`) + +- Hover animation: cards lift slightly +- Icon rotation on hover +- Smooth transitions for all interactive elements + +### BoundlessButton (`components/buttons/BoundlessButton.tsx`) + +- Scale animation on hover and tap +- Maintains all existing functionality and styling +- Wrapped in motion.div for smooth interactions + +### EmptyState (`components/EmptyState.tsx`) + +- Fade-in animation for the container +- Scale animation for the empty state image +- Staggered text animations + +### ProjectCard (`components/project-card.tsx`) + +- Card hover effects +- Badge animations with delays +- Avatar hover interactions +- Dropdown menu animations + +### RecentProjects (`components/overview/RecentProjects.tsx`) + +- Container fade-in animation +- Staggered project card animations +- Empty state animations + +## 🎯 New Components + +### PageTransition (`components/PageTransition.tsx`) + +A wrapper component that provides smooth page transitions: + +```tsx + + + +``` + +### LoadingSpinner (`components/LoadingSpinner.tsx`) + +A versatile animated loading component with multiple variants: + +#### Variants + +- `spinner`: Classic rotating spinner (default) +- `dots`: Three animated dots +- `pulse`: Pulsing circle + +#### Sizes + +- `xs`: 12px (w-3 h-3) +- `sm`: 16px (w-4 h-4) +- `md`: 24px (w-6 h-6) - default +- `lg`: 32px (w-8 h-8) +- `xl`: 48px (w-12 h-12) + +#### Colors + +- `default`: Uses current text color +- `primary`: Uses primary theme color +- `white`: White color +- `muted`: Muted text color + +#### Speeds + +- `slow`: 2 seconds +- `normal`: 1 second (default) +- `fast`: 0.5 seconds + +#### Usage Examples + +```tsx +// Basic usage + + +// Customized spinner + + +// Button loading state + + +// Page loading + +``` + +### AnimatedCounter (`components/AnimatedCounter.tsx`) + +Animated number counter for displaying changing values: + +```tsx + +``` + +## 🎨 Design Principles + +1. **Subtle**: All animations are understated and enhance rather than distract +2. **Consistent**: Using the same easing curves and timing across all animations +3. **Performance**: Optimized animations that don't impact performance +4. **Accessibility**: Animations respect user preferences and can be disabled +5. **Preserved Design**: All existing styling and functionality remains unchanged + +## 🚀 Usage Examples + +### Adding animations to new components: + +```tsx +import { motion } from 'framer-motion'; +import { fadeInUp, cardHover } from '@/lib/motion'; + +const MyComponent = () => { + return ( + + {/* Your content */} + + ); +}; +``` + +### Using the PageTransition wrapper: + +```tsx +import PageTransition from '@/components/PageTransition'; + +const MyPage = () => { + return ( + +
Page content
+
+ ); +}; +``` + +### LoadingSpinner Demo + +Check out `components/LoadingSpinnerDemo.tsx` to see all variants in action. + +## 📱 Performance Considerations + +- All animations use `transform` and `opacity` properties for optimal performance +- Animations are hardware-accelerated where possible +- Reduced motion support can be added for accessibility +- Animation durations are kept short (0.2-0.4s) for snappy interactions + +## 🎭 Customization + +To modify animations, edit the variants in `lib/motion.ts`. All animations use consistent easing curves and timing for a cohesive experience. + +The integration maintains 100% backward compatibility with existing components while adding smooth, professional animations that enhance the user experience. diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md new file mode 100644 index 000000000..7c3e4e7d2 --- /dev/null +++ b/docs/TROUBLESHOOTING.md @@ -0,0 +1,314 @@ +# Wallet Troubleshooting Guide + +## 🚨 Common Issues & Solutions + +### Issue: xBull, Albedo, and other wallets not connecting successfully + +#### **Root Cause Analysis** + +The Stellar Wallets Kit has different connection patterns for different wallet types: + +- **Freighter**: Browser extension, direct connection +- **Albedo**: Web-based, opens in new window +- **xBull**: Mobile/web wallet, may require different flow +- **Rabet**: Browser extension, similar to Freighter + +#### **Solutions** + +1. **Check Wallet Installation** + + ```bash + # Verify wallet extensions are installed + # Chrome: chrome://extensions/ + # Firefox: about:addons + ``` + +2. **Enable Wallet Extensions** + - Make sure extensions are enabled + - Check if extensions are blocked by browser + - Try refreshing the page after enabling + +3. **Network Configuration** + - Ensure wallet is set to the same network as your app + - Testnet vs Public network mismatch is common + - Switch wallet network if needed + +4. **Browser Permissions** + - Allow pop-ups for Albedo + - Grant necessary permissions when prompted + - Check browser console for permission errors + +### Issue: Signing fails after connection + +#### **Root Cause Analysis** + +- Different wallets support different signing methods +- Network passphrase mismatch +- Wallet not properly unlocked +- XDR format issues + +#### **Solutions** + +1. **Check Wallet State** + + ```javascript + // In browser console + console.log('Wallet connected:', isConnected); + console.log('Selected wallet:', selectedWallet); + console.log('Network:', network); + ``` + +2. **Test with Simple XDR** + + ```javascript + // Use a simple test XDR + const testXdr = 'AAAAAgAAAAA='; + ``` + +3. **Verify Network Passphrase** + - Testnet: `Test SDF Network ; September 2015` + - Public: `Public Global Stellar Network ; September 2015` + +4. **Check Wallet Capabilities** + - Some wallets don't support all signing methods + - Try message signing first (simpler) + - Then try transaction signing + +## 🔧 Debug Steps + +### Step 1: Check Browser Console + +1. Open Developer Tools (F12) +2. Go to Console tab +3. Look for errors when connecting +4. Check for network-related errors + +### Step 2: Use Debug Page + +1. Visit `/debug-wallet` +2. Check "Available Wallets" section +3. Test individual wallet connections +4. Use the signing test component + +### Step 3: Test Individual Wallets + +#### **Freighter** + +```javascript +// Test Freighter specifically +await connectWallet('freighter'); +``` + +#### **Albedo** + +```javascript +// Albedo opens in new window +// Make sure pop-ups are allowed +await connectWallet('albedo'); +``` + +#### **xBull** + +```javascript +// xBull may need different approach +await connectWallet('xbull'); +``` + +### Step 4: Check Network Settings + +```javascript +// Verify network configuration +console.log('Current network:', network); +console.log('Wallet network:', walletNetwork); +``` + +## 🛠️ Advanced Debugging + +### Debug Wallet Connection + +```javascript +// Add to your component +const debugConnection = async walletId => { + try { + console.log('Attempting to connect to:', walletId); + await connectWallet(walletId); + console.log('Connection successful'); + } catch (error) { + console.error('Connection failed:', error); + console.error('Error details:', { + message: error.message, + stack: error.stack, + walletId, + }); + } +}; +``` + +### Debug Signing + +```javascript +// Add to your component +const debugSigning = async xdr => { + try { + console.log('Attempting to sign XDR:', xdr); + const result = await signTransaction(xdr); + console.log('Signing successful:', result); + } catch (error) { + console.error('Signing failed:', error); + console.error('Error details:', { + message: error.message, + stack: error.stack, + xdr: xdr.substring(0, 50) + '...', + }); + } +}; +``` + +## 📱 Wallet-Specific Issues + +### Freighter + +- **Issue**: Not detected +- **Solution**: Install from https://www.freighter.app/ +- **Issue**: Permission denied +- **Solution**: Unlock wallet and approve connection + +### Albedo + +- **Issue**: Pop-up blocked +- **Solution**: Allow pop-ups for the site +- **Issue**: Connection timeout +- **Solution**: Check if Albedo site is accessible + +### xBull + +- **Issue**: Not available +- **Solution**: Install xBull extension or app +- **Issue**: Network mismatch +- **Solution**: Switch xBull to correct network + +### Rabet + +- **Issue**: Not detected +- **Solution**: Install from https://rabet.io/ +- **Issue**: Connection fails +- **Solution**: Unlock Rabet and approve connection + +## 🔍 Environment Checks + +### Browser Compatibility + +```javascript +// Check browser features +console.log('User Agent:', navigator.userAgent); +console.log('Platform:', navigator.platform); +console.log('Cookies Enabled:', navigator.cookieEnabled); +``` + +### Network Connectivity + +```javascript +// Test network connectivity +fetch('https://horizon-testnet.stellar.org') + .then(response => console.log('Horizon accessible:', response.ok)) + .catch(error => console.error('Horizon error:', error)); +``` + +### Wallet Detection + +```javascript +// Check if wallets are available +console.log('Available wallets:', availableWallets); +console.log('Wallet kit initialized:', !!walletKit); +``` + +## 🚀 Quick Fixes + +### 1. Clear Browser Data + +- Clear cookies and cache +- Refresh the page +- Try again + +### 2. Restart Wallet Extensions + +- Disable wallet extensions +- Re-enable them +- Refresh the page + +### 3. Check Network Settings + +- Ensure wallet and app use same network +- Switch to testnet for testing +- Switch to public for production + +### 4. Try Different Browser + +- Test in Chrome +- Test in Firefox +- Test in Safari + +## 📞 Getting Help + +### Before Asking for Help + +1. Check browser console for errors +2. Use the debug page (`/debug-wallet`) +3. Test with different wallets +4. Check network settings +5. Try in different browser + +### Information to Provide + +- Browser and version +- Wallet type and version +- Error messages from console +- Steps to reproduce +- Network settings (testnet/public) + +### Common Error Messages + +#### "Wallet not found" + +- Wallet not installed +- Wallet extension disabled +- Browser blocking extension + +#### "Permission denied" + +- Wallet not unlocked +- User denied permission +- Extension needs approval + +#### "Network mismatch" + +- Wallet on different network +- App configured for wrong network +- Need to switch network in wallet + +#### "Signing failed" + +- Wallet doesn't support signing +- XDR format incorrect +- Network passphrase wrong +- Wallet not properly connected + +## 🔄 Testing Checklist + +- [ ] Wallet extension installed +- [ ] Wallet extension enabled +- [ ] Wallet unlocked +- [ ] Network matches (testnet/public) +- [ ] Pop-ups allowed (for Albedo) +- [ ] Browser console checked for errors +- [ ] Debug page used +- [ ] Different browser tested +- [ ] Different wallet tested + +## 📚 Additional Resources + +- [Stellar Wallets Kit Issues](https://github.com/creit-tech/stellar-wallets-kit/issues) +- [Freighter Documentation](https://www.freighter.app/docs) +- [Albedo Documentation](https://albedo.link/) +- [xBull Documentation](https://xbull.app/) +- [Rabet Documentation](https://rabet.io/) diff --git a/docs/WALLET_INTEGRATION.md b/docs/WALLET_INTEGRATION.md index 3a55cb62c..8a4874777 100644 --- a/docs/WALLET_INTEGRATION.md +++ b/docs/WALLET_INTEGRATION.md @@ -1,380 +1,521 @@ -# Stellar Wallet Integration +# Stellar Wallets Kit Integration -A complete frontend Stellar wallet integration system built with Next.js, Freighter, Zustand, and TypeScript. +## Overview -## 🚀 Features +This project implements a comprehensive multi-wallet solution for Stellar using the **Stellar Wallets Kit** with a **hybrid approach** for Freighter wallet support. The integration provides seamless connectivity to multiple Stellar wallets while ensuring maximum compatibility with the most popular wallet - Freighter. -- **🔐 Wallet Connect**: Seamless Freighter wallet integration -- **📦 State Management**: Global wallet state with Zustand -- **✍️ Transaction Signing**: Complete XDR signing flow -- **🎨 UI Components**: Beautiful, responsive components with shadcn/ui -- **📱 Mobile Responsive**: Works perfectly on all devices -- **♿ Accessible**: Full accessibility support -- **🔧 TypeScript**: Complete type safety +## 🎯 **Hybrid Approach** -## 📦 Installation +### **Primary: Stellar Wallets Kit** -### Dependencies +- **Multi-wallet support**: Freighter, Albedo, Rabet, xBull, Lobstr, Hana, HOT Wallet +- **Unified API**: Consistent interface across all supported wallets +- **Enhanced features**: Message signing, auth entry signing, network switching -```bash -npm install zustand @radix-ui/react-dialog lucide-react sonner -``` +### **Fallback: Direct Freighter API** -### Optional: Stellar SDK (for production) +- **Reliability**: When Stellar Wallets Kit doesn't detect Freighter +- **Direct integration**: Uses `@stellar/freighter-api` as backup +- **Full compatibility**: Ensures Freighter always works when installed -```bash -npm install stellar-sdk +## 🏗️ **Architecture** + +``` +┌─────────────────────────────────────────────────────────────┐ +│ Wallet Integration │ +├─────────────────────────────────────────────────────────────┤ +│ ┌─────────────────┐ ┌─────────────────────────────┐ │ +│ │ Stellar Wallets │ │ Direct Freighter API │ │ +│ │ Kit │ │ (@stellar/freighter-api) │ │ +│ │ │ │ │ │ +│ │ • Multi-wallet │ │ • Freighter fallback │ │ +│ │ • Unified API │ │ • Direct connection │ │ +│ │ • Enhanced │ │ • Reliable detection │ │ +│ │ features │ │ │ │ +│ └─────────────────┘ └─────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────┘ ``` -## 🏗️ Architecture +## 🔧 **Implementation Details** + +### **Wallet Detection Strategy** + +1. **Primary Detection**: Stellar Wallets Kit scans for available wallets +2. **Freighter Fallback**: If Freighter not detected by kit, check direct API +3. **Hybrid Connection**: Try kit first, fallback to direct API for Freighter + +### **Connection Flow** + +```typescript +// For Freighter wallet +if (walletId === 'freighter') { + try { + // 1. Try Stellar Wallets Kit first + walletKit.setWallet('freighter'); + const address = await walletKit.getAddress(); + // Success via kit + } catch (kitError) { + // 2. Fallback to direct Freighter API + const available = await checkFreighterAvailability(); + if (available) { + await freighterSetAllowed(); + const address = await freighterGetAddress(); + // Success via direct API + } + } +} +``` -### File Structure +### **Signing Capabilities** -``` -├── hooks/ -│ └── use-wallet.ts # Zustand store for wallet state -├── components/wallet/ -│ ├── WalletConnectButton.tsx # Wallet connection component -│ ├── SignTransactionButton.tsx # Transaction signing component -│ └── TxResultToast.tsx # Transaction result display -├── app/api/tx/ -│ ├── prepare/route.ts # Prepare unsigned XDR -│ └── submit/route.ts # Submit signed XDR -└── app/wallet-demo/ - └── page.tsx # Demo page -``` +| Wallet | Transaction | Message | Auth Entry | API Used | +| ------------------ | ----------- | ------- | ---------- | ---------------------- | +| Freighter (Kit) | ✅ | ✅ | ✅ | Stellar Wallets Kit | +| Freighter (Direct) | ✅ | ❌ | ❌ | @stellar/freighter-api | +| Albedo | ✅ | ❌ | ❌ | Stellar Wallets Kit | +| Rabet | ✅ | ✅ | ✅ | Stellar Wallets Kit | +| xBull | ✅ | ✅ | ✅ | Stellar Wallets Kit | +| Lobstr | ✅ | ❌ | ❌ | Stellar Wallets Kit | +| Hana | ✅ | ❌ | ❌ | Stellar Wallets Kit | +| HOT Wallet | ✅ | ✅ | ✅ | Stellar Wallets Kit | -## 🔧 Usage +## 🚀 **Features** -### 1. Wallet Store (useWalletStore) +### **Multi-Wallet Support** -The Zustand store manages all wallet state and operations: +- **Freighter**: Browser extension (primary wallet) +- **Albedo**: Web-based wallet +- **Rabet**: Browser extension +- **xBull**: Mobile and web wallet +- **Lobstr**: Mobile wallet +- **Hana**: Mobile wallet +- **HOT Wallet**: Hardware wallet interface -```tsx -import { useWalletStore } from '@/hooks/use-wallet'; +### **Network Management** -const { publicKey, network, isConnected, connectWallet, signXDR } = - useWalletStore(); -``` +- **Testnet**: Development and testing +- **Public**: Production network +- **Dynamic switching**: Seamless network transitions +- **Network detection**: Automatic network detection from wallets -#### State Properties +### **Enhanced Signing** -- `publicKey: string | null` - Connected wallet's public key -- `network: 'testnet' | 'public'` - Current Stellar network -- `isConnected: boolean` - Wallet connection status -- `isLoading: boolean` - Loading state for operations -- `error: string | null` - Error messages +- **Transaction signing**: XDR transaction signing +- **Message signing**: Text message signing (where supported) +- **Auth entry signing**: Soroban auth entry signing (where supported) -#### Actions +### **User Experience** -- `connectWallet(): Promise` - Connect to Freighter wallet -- `disconnectWallet(): void` - Disconnect wallet -- `signXDR(xdr: string): Promise` - Sign XDR with wallet -- `setError(error: string | null): void` - Set error state -- `clearError(): void` - Clear error state +- **Wallet selection modal**: Choose from available wallets +- **Network switcher**: Switch between Testnet and Public +- **Real-time status**: Live connection status updates +- **Error handling**: Comprehensive error messages +- **Capability indicators**: Show what each wallet supports -### 2. WalletConnectButton +## 📦 **Installation** -A complete wallet connection component: +### **Dependencies** -```tsx -import WalletConnectButton from '@/components/wallet/WalletConnectButton'; +```bash +npm install @creit.tech/stellar-wallets-kit @stellar/freighter-api +``` + +### **Environment Variables** -; +```env +# Optional: WalletConnect Project ID (if using WalletConnect) +NEXT_PUBLIC_WALLET_CONNECT_PROJECT_ID=your_project_id ``` -#### Props +## 🛠️ **Usage** -- `variant?: 'default' | 'outline' | 'ghost'` - Button variant -- `size?: 'default' | 'sm' | 'lg'` - Button size -- `className?: string` - Additional CSS classes +### **Basic Wallet Connection** -### 3. SignTransactionButton +```typescript +import { useWalletStore } from '@/hooks/use-wallet'; -Handles the complete transaction signing flow: +const { connectWallet, isConnected, publicKey } = useWalletStore(); -```tsx -import SignTransactionButton from '@/components/wallet/SignTransactionButton'; - - console.log('Success:', hash)} - onError={error => console.error('Error:', error)} -> - Send Payment -; +// Connect to Freighter +await connectWallet('freighter'); + +// Check connection status +if (isConnected) { + console.log('Connected to:', publicKey); +} ``` -#### Props +### **Network Switching** -- `transactionParams?: Record` - Transaction parameters -- `prepareEndpoint?: string` - Custom prepare API endpoint -- `submitEndpoint?: string` - Custom submit API endpoint -- `onSuccess?: (hash: string) => void` - Success callback -- `onError?: (error: string) => void` - Error callback +```typescript +import { useNetworkSwitcher } from '@/hooks/use-wallet'; -### 4. TxResultToast +const { currentNetwork, switchToNetwork } = useNetworkSwitcher(); -Displays transaction results with StellarExpert links: +// Switch to Public network +await switchToNetwork('public'); +``` -```tsx -import TxResultToast from '@/components/wallet/TxResultToast'; - - setShowToast(false)} -/>; +### **Transaction Signing** + +```typescript +import { useWalletSigning } from '@/hooks/use-wallet'; + +const { signTransaction, canSignTransaction } = useWalletSigning(); + +if (canSignTransaction) { + const signedXdr = await signTransaction(xdrTransaction); + console.log('Signed transaction:', signedXdr); +} +``` + +### **Message Signing** + +```typescript +import { useWalletSigning } from '@/hooks/use-wallet'; + +const { signMessage, canSignMessage } = useWalletSigning(); + +if (canSignMessage) { + const signature = await signMessage('Hello, Stellar!'); + console.log('Message signature:', signature); +} ``` -## 🔄 Transaction Flow +## 🎨 **UI Components** -### 1. Prepare Transaction +### **ConnectWallet Modal** ```tsx -// POST /api/tx/prepare -const response = await fetch('/api/tx/prepare', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - network: 'testnet', - type: 'payment', - amount: '10', - destination: 'GABC...XYZ', - }), -}); - -const { xdr } = await response.json(); +import ConnectWallet from '@/components/connect-wallet'; + +; ``` -### 2. Sign XDR +### **Network Switcher** ```tsx -const signedXDR = await signXDR(xdr); +import NetworkSwitcher from '@/components/wallet/NetworkSwitcher'; + +; ``` -### 3. Submit Transaction +### **Wallet Signing Panel** ```tsx -// POST /api/tx/submit -const response = await fetch('/api/tx/submit', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - signedXdr: signedXDR, - network: 'testnet', - }), -}); - -const { hash, success } = await response.json(); +import WalletSigningPanel from '@/components/wallet/WalletSigningPanel'; + +; ``` -## 🎨 Styling +## 🔍 **Debugging** -### Theme Integration +### **Debug Page** -The components use shadcn/ui and Tailwind CSS: +Visit `/debug-wallet` for comprehensive debugging: -```tsx -// Custom styling - -``` +- **Wallet Kit Status**: Connection state and available wallets +- **Network Information**: Current network and switching capabilities +- **Signing Tests**: Test transaction and message signing +- **Error Logging**: Detailed error information -### Responsive Design +### **Console Logging** -All components are mobile-responsive: +The integration provides detailed console logging: -```css -/* Mobile-first approach */ -.wallet-button { - @apply w-full md:w-auto; - @apply text-sm md:text-base; - @apply px-3 md:px-6; -} +```javascript +// Wallet detection +console.log('Available wallets from kit:', availableWallets); +console.log('Freighter available via direct API, adding to available wallets'); + +// Connection flow +console.log('Connecting to wallet: freighter on network: testnet'); +console.log('Freighter connected via kit:', addressResult); +console.log('Freighter connected via direct API:', address); + +// Signing operations +console.log( + 'Signing transaction with wallet: freighter usingFreighterAPI: false' +); +console.log('Transaction signed successfully via wallet kit'); ``` -## 🔒 Security +## 🚨 **Error Handling** -### Best Practices +### **Common Error Scenarios** -1. **Client-side Validation**: Always validate inputs -2. **Error Handling**: Comprehensive error handling -3. **Network Validation**: Verify network before transactions -4. **XDR Validation**: Validate XDR before signing -5. **User Confirmation**: Always confirm before signing +1. **Wallet Not Available** -### Freighter Integration + ```javascript + // Error: Freighter is not available. Please install the Freighter extension. + // Solution: Install the wallet extension + ``` -```tsx -// Check if Freighter is installed -if (!window.freighterApi) { - throw new Error('Freighter wallet is not installed'); -} +2. **Network Mismatch** -// Check connection -const isConnected = await window.freighterApi.isConnected(); -if (!isConnected) { - throw new Error('Please connect your wallet first'); -} -``` + ```javascript + // Error: Network mismatch. Please switch freighter to testnet network. + // Solution: Switch wallet to correct network + ``` -## 🧪 Testing +3. **Unsupported Feature** + ```javascript + // Error: Message signing is not supported by albedo + // Solution: Use a wallet that supports the feature + ``` -### Demo Page +### **Error Recovery** -Visit `/wallet-demo` to test the complete integration: +- **Automatic retry**: Connection attempts with fallback +- **User feedback**: Clear error messages with solutions +- **Graceful degradation**: Disable unsupported features -- Connect/disconnect wallet -- Sign different transaction types -- View transaction results -- Test responsive design +## 🔄 **Migration Guide** -### Test Networks +### **From Direct Freighter API** -- **Testnet**: Safe for testing, free XLM from Friendbot -- **Public**: Real transactions, real XLM +```typescript +// Old: Direct Freighter API +import { setAllowed, getAddress } from '@stellar/freighter-api'; -## 🚀 Production Setup +await setAllowed(); +const address = await getAddress(); -### 1. Install Stellar SDK +// New: Hybrid approach (automatic fallback) +import { useWalletStore } from '@/hooks/use-wallet'; -```bash -npm install stellar-sdk +const { connectWallet } = useWalletStore(); +await connectWallet('freighter'); // Uses kit or falls back to direct API ``` -### 2. Update API Routes +### **From Single Wallet** -Replace mock implementations with real Stellar SDK: +```typescript +// Old: Single wallet implementation +const connectFreighter = async () => { + // Direct Freighter implementation +}; -```tsx -// app/api/tx/prepare/route.ts -import { Transaction, Networks, Asset, Operation } from 'stellar-sdk'; - -// Create real transactions -const transaction = new Transaction(operation, { - fee: '100', - networkPassphrase: Networks.TESTNET, -}); +// New: Multi-wallet with unified API +const { connectWallet } = useWalletStore(); + +// Connect to any supported wallet +await connectWallet('freighter'); +await connectWallet('albedo'); +await connectWallet('rabet'); ``` -### 3. Environment Variables +## 📚 **API Reference** + +### **useWalletStore** + +Main wallet state management hook. + +```typescript +const { + // State + publicKey, + network, + isConnected, + isLoading, + error, + selectedWallet, + availableWallets, + + // Actions + initializeWalletKit, + connectWallet, + disconnectWallet, + switchNetwork, + signTransaction, + signMessage, + signAuthEntry, + + // Utilities + setError, + clearError, + getWalletInfo, +} = useWalletStore(); +``` -```env -# .env.local -STELLAR_NETWORK=testnet -HORIZON_URL=https://horizon-testnet.stellar.org +### **useWalletSigning** + +Hook for signing operations. + +```typescript +const { + signTransaction, + signMessage, + signAuthEntry, + canSignTransaction, + canSignMessage, + canSignAuthEntry, + isConnected, +} = useWalletSigning(); ``` -### 4. Error Handling +### **useNetworkSwitcher** -```tsx -// Add comprehensive error handling -try { - const result = await submitTransaction(signedXDR); - // Handle success -} catch (error) { - if (error.code === 'tx_failed') { - // Handle transaction failure - } else if (error.code === 'tx_bad_seq') { - // Handle sequence number error - } -} +Hook for network management. + +```typescript +const { currentNetwork, switchToNetwork, isLoading } = useNetworkSwitcher(); ``` -## 📱 Mobile Support +## 🧪 **Testing** -### Touch Interactions +### **Manual Testing** -- Large touch targets (44px minimum) -- Swipe gestures for mobile -- Responsive button sizes +1. **Install wallet extensions** (Freighter, Rabet) +2. **Visit debug page** (`/debug-wallet`) +3. **Test wallet connections** for each supported wallet +4. **Test network switching** between Testnet and Public +5. **Test signing operations** with different wallets -### Performance +### **Automated Testing** -- Lazy loading for heavy components -- Optimized bundle size -- Efficient state updates +```typescript +// Test wallet connection +const { connectWallet, isConnected } = useWalletStore(); +await connectWallet('freighter'); +expect(isConnected).toBe(true); -## 🔧 Customization +// Test network switching +const { switchToNetwork } = useNetworkSwitcher(); +await switchToNetwork('public'); +expect(currentNetwork).toBe('public'); -### Custom Transaction Types +// Test signing +const { signTransaction } = useWalletSigning(); +const signedXdr = await signTransaction(testXdr); +expect(signedXdr).toBeDefined(); +``` -```tsx -// Add new transaction types -const customTransaction = { - type: 'custom', - operation: 'myCustomOp', - params: { - /* custom params */ +## 🔧 **Configuration** + +### **Wallet Detection** + +The system automatically detects available wallets: + +1. **Stellar Wallets Kit**: Primary detection method +2. **Direct API Check**: Fallback for Freighter +3. **User Selection**: Manual wallet selection + +### **Network Configuration** + +```typescript +// Default network +const defaultNetwork: StellarNetwork = 'testnet'; + +// Network switching +const networks = [ + { id: 'testnet', name: 'Testnet' }, + { id: 'public', name: 'Public' }, +]; +``` + +### **Capability Mapping** + +```typescript +const walletCapabilities = { + freighter: { + canSignTransaction: true, + canSignMessage: true, + canSignAuthEntry: true, + }, + albedo: { + canSignTransaction: true, + canSignMessage: false, + canSignAuthEntry: false, }, + // ... other wallets }; ``` -### Custom Styling +## 🚀 **Performance** -```tsx -// Override default styles -const customWalletButton = styled(WalletConnectButton)` - background: linear-gradient(45deg, #ff6b6b, #4ecdc4); - border-radius: 25px; - box-shadow: 0 4px 15px rgba(0, 0, 0, 0.2); -`; -``` +### **Optimizations** -## 🐛 Troubleshooting +- **Lazy loading**: Wallet modules loaded on demand +- **Caching**: Wallet state persisted in localStorage +- **Error recovery**: Automatic retry with fallback +- **Network detection**: Efficient network switching -### Common Issues +### **Bundle Size** -1. **Freighter not detected** - - Ensure Freighter extension is installed - - Check if extension is enabled - - Refresh page after installation +- **Stellar Wallets Kit**: ~50KB +- **Direct Freighter API**: ~10KB +- **Total impact**: Minimal increase in bundle size -2. **Network mismatch** - - Verify wallet network matches app network - - Switch network in Freighter if needed +## 🔒 **Security** -3. **Transaction failures** - - Check account balance - - Verify sequence numbers - - Ensure proper asset trustlines +### **Best Practices** -### Debug Mode +- **Permission handling**: Proper wallet permission management +- **Error boundaries**: Graceful error handling +- **Input validation**: Validate XDR and message inputs +- **Network verification**: Ensure correct network usage -```tsx -// Enable debug logging -const DEBUG = process.env.NODE_ENV === 'development'; +### **Wallet Security** -if (DEBUG) { - console.log('Wallet state:', useWalletStore.getState()); -} -``` +- **Extension verification**: Verify wallet extensions +- **Network validation**: Validate network compatibility +- **Transaction verification**: Verify transaction integrity + +## 📈 **Future Enhancements** + +### **Planned Features** + +- **WalletConnect support**: Mobile wallet connectivity +- **Hardware wallet support**: Ledger integration +- **Batch operations**: Multiple transaction signing +- **Advanced signing**: Soroban contract interactions + +### **Performance Improvements** + +- **WebAssembly**: Native performance for signing +- **Streaming**: Real-time wallet status updates +- **Offline support**: Offline transaction preparation + +## 🤝 **Contributing** + +### **Development Setup** + +1. **Clone repository** +2. **Install dependencies**: `npm install` +3. **Start development**: `npm run dev` +4. **Test wallets**: Visit `/debug-wallet` + +### **Testing Guidelines** + +- **Test all wallets**: Ensure each wallet works +- **Test network switching**: Verify network transitions +- **Test signing operations**: Validate all signing methods +- **Test error scenarios**: Handle edge cases + +### **Code Standards** + +- **TypeScript**: Full type safety +- **Error handling**: Comprehensive error management +- **Documentation**: Clear API documentation +- **Testing**: Unit and integration tests -## 📄 License +## 📞 **Support** -This project is part of the Boundless ecosystem and follows the project's licensing terms. +### **Common Issues** -## 🤝 Contributing +1. **Wallet not detected**: Check browser extensions +2. **Network mismatch**: Verify wallet network settings +3. **Signing failures**: Check wallet capabilities +4. **Connection errors**: Review console logs -1. Fork the repository -2. Create a feature branch -3. Add tests for new functionality -4. Ensure all tests pass -5. Submit a pull request +### **Getting Help** -## 📞 Support +- **Debug page**: `/debug-wallet` for troubleshooting +- **Console logs**: Detailed error information +- **Documentation**: Comprehensive guides +- **Community**: Stellar developer community -For support and questions: +--- -- Create an issue on GitHub -- Check the documentation -- Review the demo page +This hybrid approach ensures maximum compatibility with Freighter while providing a unified experience across all supported Stellar wallets. The system automatically chooses the best available method for each wallet, providing users with the most reliable and feature-rich experience possible. diff --git a/docs/WALLET_SETUP.md b/docs/WALLET_SETUP.md new file mode 100644 index 000000000..23cd77360 --- /dev/null +++ b/docs/WALLET_SETUP.md @@ -0,0 +1,240 @@ +# Wallet Integration Setup Guide + +## 🚀 Quick Start + +### 1. Environment Variables + +Create a `.env.local` file in your project root with the following variables: + +```env +# Stellar Wallets Kit Configuration +NEXT_PUBLIC_STELLAR_NETWORK=testnet +NEXT_PUBLIC_HORIZON_URL=https://horizon-testnet.stellar.org +NEXT_PUBLIC_SOROBAN_RPC_URL=https://soroban-testnet.stellar.org +NEXT_PUBLIC_APP_URL=http://localhost:3000 + +# For production, change to: +# NEXT_PUBLIC_STELLAR_NETWORK=public +# NEXT_PUBLIC_HORIZON_URL=https://horizon.stellar.org +# NEXT_PUBLIC_SOROBAN_RPC_URL=https://soroban.stellar.org +``` + +### 2. Install Dependencies + +The Stellar Wallets Kit is already installed. If you need to reinstall: + +```bash +npm install @creit.tech/stellar-wallets-kit +``` + +### 3. Test the Integration + +1. Start your development server: + + ```bash + npm run dev + ``` + +2. Visit the debug page: `http://localhost:3000/debug-wallet` + +3. Visit the demo page: `http://localhost:3000/wallet-demo` + +## 🔧 Troubleshooting + +### Wallet Not Connecting + +#### 1. Check Browser Console + +Open your browser's developer tools and check the console for any errors. Common issues: + +- **Module not found errors**: Make sure all dependencies are installed +- **Network errors**: Check if you're on the correct network (testnet vs public) +- **Permission errors**: Make sure the wallet extension is installed and unlocked + +#### 2. Verify Wallet Installation + +**Freighter:** + +- Install from: https://www.freighter.app/ +- Make sure it's unlocked and connected to the correct network +- Check if the extension is enabled in your browser + +**Albedo:** + +- Visit: https://albedo.link/ +- No installation required, but make sure pop-ups are allowed + +**Rabet:** + +- Install from: https://rabet.io/ +- Make sure it's unlocked and connected + +#### 3. Check Network Settings + +Make sure your wallet is set to the same network as your app: + +- **Testnet**: For development and testing +- **Public**: For production + +#### 4. Debug Steps + +1. **Visit the debug page**: `/debug-wallet` +2. **Check available wallets**: See which wallets are detected +3. **Test individual wallets**: Use the test buttons +4. **Check browser info**: Verify your browser supports the required features + +### Common Issues + +#### Issue: "No wallets detected" + +**Solution:** + +- Make sure you have at least one Stellar wallet installed +- Try refreshing the page +- Check if the wallet extension is enabled + +#### Issue: "Wallet not found" + +**Solution:** + +- Verify the wallet ID matches the expected format +- Check if the wallet is properly installed +- Try reconnecting the wallet + +#### Issue: "Network mismatch" + +**Solution:** + +- Switch your wallet to the correct network (testnet/public) +- Update the environment variables to match your wallet's network + +#### Issue: "Permission denied" + +**Solution:** + +- Make sure the wallet is unlocked +- Grant necessary permissions when prompted +- Check if the wallet extension is properly installed + +## 🧪 Testing + +### Manual Testing + +1. **Debug Page**: `/debug-wallet` + - Shows wallet kit status + - Lists available wallets + - Provides test buttons + - Shows browser information + +2. **Demo Page**: `/wallet-demo` + - Full feature demonstration + - Network switching + - Signing capabilities + - UI components + +### Automated Testing + +```bash +# Run tests (if available) +npm test + +# Type checking +npm run type-check + +# Linting +npm run lint +``` + +## 📱 Supported Wallets + +### Currently Supported + +- **Freighter** - Browser extension +- **Albedo** - Web-based wallet +- **Rabet** - Browser extension +- **xBull** - Mobile and web wallet +- **Lobstr** - Mobile wallet +- **Hana** - Mobile wallet +- **HOT Wallet** - Hardware wallet interface + +### Coming Soon + +- **WalletConnect** - Multi-wallet protocol (requires additional setup) + +## 🔄 Migration from Old Implementation + +If you're migrating from the old Freighter-only implementation: + +1. **Update imports**: + + ```tsx + // Old + import { setAllowed, getAddress } from '@stellar/freighter-api'; + + // New + import { useWalletStore } from '@/hooks/use-wallet'; + ``` + +2. **Update connection logic**: + + ```tsx + // Old + await setAllowed(); + const result = await getAddress(); + + // New + const { connectWallet } = useWalletStore(); + await connectWallet('freighter'); + ``` + +3. **Update signing logic**: + + ```tsx + // Old + const signedXdr = await signTransaction(xdr); + + // New + const { signTransaction } = useWalletStore(); + const signedXdr = await signTransaction(xdr); + ``` + +## 🚀 Production Deployment + +### Environment Variables for Production + +```env +NEXT_PUBLIC_STELLAR_NETWORK=public +NEXT_PUBLIC_HORIZON_URL=https://horizon.stellar.org +NEXT_PUBLIC_SOROBAN_RPC_URL=https://soroban.stellar.org +NEXT_PUBLIC_APP_URL=https://your-domain.com +``` + +### Build and Deploy + +```bash +# Build the application +npm run build + +# Start production server +npm start +``` + +## 📚 Additional Resources + +- [Stellar Wallets Kit Documentation](https://github.com/creit-tech/stellar-wallets-kit) +- [Stellar Protocol Documentation](https://developers.stellar.org/) +- [Soroban Documentation](https://soroban.stellar.org/) +- [Freighter Documentation](https://www.freighter.app/docs) +- [Albedo Documentation](https://albedo.link/) + +## 🤝 Support + +If you're still having issues: + +1. Check the debug page for detailed information +2. Review the browser console for errors +3. Verify wallet installation and permissions +4. Test with different wallets +5. Check network settings + +For additional help, refer to the main documentation or create an issue in the project repository. diff --git a/env.example b/env.example new file mode 100644 index 000000000..42e62cbd5 --- /dev/null +++ b/env.example @@ -0,0 +1,29 @@ +# Stellar Network Configuration +NEXT_PUBLIC_STELLAR_NETWORK=testnet +# Options: testnet, public + +# Application Configuration +NEXT_PUBLIC_APP_URL=http://localhost:3000 +# Your application's public URL + +# WalletConnect Configuration +NEXT_PUBLIC_WALLET_CONNECT_PROJECT_ID=your_wallet_connect_project_id +# Get this from https://cloud.walletconnect.com/ + +# Stellar Horizon URLs (optional - defaults will be used) +NEXT_PUBLIC_HORIZON_TESTNET_URL=https://horizon-testnet.stellar.org +NEXT_PUBLIC_HORIZON_PUBLIC_URL=https://horizon.stellar.org + +# Application Metadata +NEXT_PUBLIC_APP_NAME=Boundless +NEXT_PUBLIC_APP_DESCRIPTION=Stellar-based application +NEXT_PUBLIC_APP_ICON=/logo.svg + +# Development Settings +NEXT_PUBLIC_DEBUG_MODE=false +# Enable debug logging for wallet operations + +# Feature Flags +NEXT_PUBLIC_ENABLE_WALLET_CONNECT=true +NEXT_PUBLIC_ENABLE_MULTI_WALLET=true +NEXT_PUBLIC_ENABLE_NETWORK_SWITCHING=true diff --git a/hooks/use-auth.ts b/hooks/use-auth.ts index 11d405411..ce445d3bc 100644 --- a/hooks/use-auth.ts +++ b/hooks/use-auth.ts @@ -1,24 +1,59 @@ import { useSession } from 'next-auth/react'; import { useRouter } from 'next/navigation'; import { useEffect } from 'react'; +import { useAuthStore } from '@/lib/stores/auth-store'; +import { refreshUserData } from '@/lib/api/auth'; +// Enhanced auth hook that works with Zustand store export function useAuth(requireAuth = true) { const { data: session, status } = useSession(); const router = useRouter(); + // Get Zustand store state + const { + user, + isAuthenticated, + isLoading: storeLoading, + error, + refreshUser, + clearAuth, + } = useAuthStore(); + + // Determine if we should use Zustand store or NextAuth session + const shouldUseStore = isAuthenticated || user; + + const authData = shouldUseStore + ? { + user, + isAuthenticated, + isLoading: storeLoading, + error, + refreshUser, + clearAuth, + } + : { + user: session?.user, + isAuthenticated: status === 'authenticated', + isLoading: status === 'loading', + error: null, + refreshUser: () => refreshUserData(), + clearAuth: () => clearAuth(), + }; + useEffect(() => { - if (requireAuth && status === 'unauthenticated') { + if (requireAuth && !authData.isAuthenticated && !authData.isLoading) { router.push('/auth/signin'); } - }, [requireAuth, status, router]); + }, [requireAuth, authData.isAuthenticated, authData.isLoading, router]); - return { - session, - status, - isAuthenticated: status === 'authenticated', - isLoading: status === 'loading', - user: session?.user, - }; + // Auto-refresh user data on mount if authenticated + useEffect(() => { + if (shouldUseStore && isAuthenticated && !user) { + refreshUser().catch(() => {}); + } + }, [shouldUseStore, isAuthenticated, user, refreshUser]); + + return authData; } export function useRequireAuth() { @@ -28,3 +63,83 @@ export function useRequireAuth() { export function useOptionalAuth() { return useAuth(false); } + +// New hook for Zustand-only auth (when not using NextAuth) +export function useZustandAuth(requireAuth = true) { + const router = useRouter(); + const { + user, + isAuthenticated, + isLoading, + error, + refreshUser, + clearAuth, + login, + logout, + } = useAuthStore(); + + useEffect(() => { + if (requireAuth && !isAuthenticated && !isLoading) { + router.push('/auth/signin'); + } + }, [requireAuth, isAuthenticated, isLoading, router]); + + // Auto-refresh user data on mount if authenticated + useEffect(() => { + if (isAuthenticated && !user) { + refreshUser().catch(() => {}); + } + }, [isAuthenticated, user, refreshUser]); + + return { + user, + isAuthenticated, + isLoading, + error, + login, + logout, + refreshUser, + clearAuth, + }; +} + +// Hook for checking auth status without redirecting +export function useAuthStatus() { + const { isAuthenticated, isLoading, user } = useAuthStore(); + + return { + isAuthenticated, + isLoading, + user, + }; +} + +// Hook for auth actions +export function useAuthActions() { + const { login, logout, refreshUser, clearAuth, setError } = useAuthStore(); + + return { + login, + logout, + refreshUser, + clearAuth, + setError, + }; +} + +// Hook for handling 401 errors +export function useAuthErrorHandler() { + const { clearAuth } = useAuthStore(); + const router = useRouter(); + + const handleAuthError = (error: { status?: number; code?: string }) => { + if (error?.status === 401 || error?.code === 'UNAUTHORIZED') { + clearAuth(); + router.push('/auth/signin'); + return true; // Error was handled + } + return false; // Error was not handled + }; + + return { handleAuthError }; +} diff --git a/hooks/use-wallet.ts b/hooks/use-wallet.ts index 88d281538..c3299c22f 100644 --- a/hooks/use-wallet.ts +++ b/hooks/use-wallet.ts @@ -2,36 +2,118 @@ import { useEffect, useState } from 'react'; import { create } from 'zustand'; import { persist } from 'zustand/middleware'; import { - isConnected, - getAddress, - getNetwork, - setAllowed, - signTransaction, + StellarWalletsKit, + WalletNetwork, + ISupportedWallet, + FreighterModule, + AlbedoModule, + RabetModule, + xBullModule, + LobstrModule, + HanaModule, + HotWalletModule, +} from '@creit.tech/stellar-wallets-kit'; +// Import Freighter API as fallback +import { + isConnected as freighterIsConnected, + getAddress as freighterGetAddress, + getNetwork as freighterGetNetwork, + setAllowed as freighterSetAllowed, + signTransaction as freighterSignTransaction, } from '@stellar/freighter-api'; import { formatPublicKey } from '@/lib/utils'; export type StellarNetwork = 'testnet' | 'public'; -interface WalletState { +export interface WalletState { + // Core wallet state publicKey: string | null; network: StellarNetwork; isConnected: boolean; isLoading: boolean; error: string | null; - connectWallet: () => Promise; + // Wallet kit instance + walletKit: StellarWalletsKit | null; + selectedWallet: string | null; + availableWallets: ISupportedWallet[]; + + // Enhanced signing capabilities + canSignTransaction: boolean; + canSignMessage: boolean; + canSignAuthEntry: boolean; + + // Actions + initializeWalletKit: (network?: StellarNetwork) => Promise; + connectWallet: (walletId: string) => Promise; disconnectWallet: () => void; - signXDR: (xdr: string) => Promise; + switchNetwork: (network: StellarNetwork) => Promise; + + // Enhanced signing methods + signTransaction: (xdr: string) => Promise; + signMessage: (message: string) => Promise; + signAuthEntry: (authEntry: string) => Promise; + + // Utility methods setError: (error: string | null) => void; clearError: () => void; + getWalletInfo: () => { address: string; network: string } | null; } -let cachedAddress: string | null = null; -let cachedNetwork: StellarNetwork | null = null; -let connectionPromise: Promise<{ - address: string; - network: StellarNetwork; -}> | null = null; +let walletKitInstance: StellarWalletsKit | null = null; +let currentNetwork: StellarNetwork = 'testnet'; +let usingFreighterAPI = false; // Track if we're using direct Freighter API + +// Wallet capability mapping +const walletCapabilities = { + freighter: { + canSignTransaction: true, + canSignMessage: true, + canSignAuthEntry: true, + }, + albedo: { + canSignTransaction: true, + canSignMessage: false, // Albedo doesn't support message signing + canSignAuthEntry: false, // Albedo doesn't support auth entry signing + }, + rabet: { + canSignTransaction: true, + canSignMessage: true, + canSignAuthEntry: true, + }, + xbull: { + canSignTransaction: true, + canSignMessage: true, + canSignAuthEntry: true, + }, + lobstr: { + canSignTransaction: true, + canSignMessage: false, + canSignAuthEntry: false, + }, + hana: { + canSignTransaction: true, + canSignMessage: false, + canSignAuthEntry: false, + }, + 'hot-wallet': { + canSignTransaction: true, + canSignMessage: true, + canSignAuthEntry: true, + }, +}; + +// Check if Freighter is available via direct API +const checkFreighterAvailability = async (): Promise => { + try { + const connected = await freighterIsConnected(); + console.log('Freighter direct API check:', connected); + return Boolean(connected); + } catch (error) { + console.log('Freighter direct API not available:', error); + return false; + } +}; export const useWalletStore = create()( persist( @@ -41,68 +123,268 @@ export const useWalletStore = create()( isConnected: false, isLoading: false, error: null, + walletKit: null, + selectedWallet: null, + availableWallets: [], + canSignTransaction: false, + canSignMessage: false, + canSignAuthEntry: false, + + initializeWalletKit: async (network: StellarNetwork = 'testnet') => { + try { + currentNetwork = network; - connectWallet: async () => { - set({ isLoading: true, error: null }); + // If we already have a wallet kit, disconnect it first + if (walletKitInstance) { + walletKitInstance.disconnect().catch(() => {}); + walletKitInstance = null; + } - try { - if (cachedAddress && cachedNetwork) { - set({ - publicKey: cachedAddress, - network: cachedNetwork, - isConnected: true, - isLoading: false, - error: null, - }); - return; + // Create modules - start with the most common ones + const modules = [ + new FreighterModule(), + new AlbedoModule(), + new RabetModule(), + new xBullModule(), + new LobstrModule(), + new HanaModule(), + new HotWalletModule(), + ]; + + // Convert our network type to WalletNetwork enum + const walletNetwork = + network === 'testnet' + ? WalletNetwork.TESTNET + : WalletNetwork.PUBLIC; + + console.log( + 'Initializing wallet kit with network:', + network, + 'WalletNetwork:', + walletNetwork + ); + + const kit = new StellarWalletsKit({ + network: walletNetwork, + selectedWalletId: 'freighter', + modules, + }); + + walletKitInstance = kit; + + // Get available wallets + const availableWallets = await kit.getSupportedWallets(); + console.log('Available wallets from kit:', availableWallets); + + // Check if Freighter is available via direct API if not detected by kit + const freighterAvailable = availableWallets.some( + wallet => wallet.id === 'freighter' && wallet.isAvailable + ); + if (!freighterAvailable) { + const directFreighterAvailable = await checkFreighterAvailability(); + if (directFreighterAvailable) { + console.log( + 'Freighter available via direct API, adding to available wallets' + ); + // Add Freighter to available wallets if detected via direct API + availableWallets.push({ + id: 'freighter', + name: 'Freighter', + icon: '/wallets/freighter.svg', + isAvailable: true, + type: 'extension', + url: 'https://www.freighter.app/', + } as ISupportedWallet); + } } - if (!connectionPromise) { - connectionPromise = (async () => { - await setAllowed(); + set({ + walletKit: kit, + availableWallets, + network, + }); + } catch (error) { + console.error('Failed to initialize wallet kit:', error); + set({ + error: + error instanceof Error + ? error.message + : 'Failed to initialize wallet kit', + }); + } + }, + + connectWallet: async (walletId: string) => { + const { walletKit } = get(); - const connected = await isConnected(); - if (!connected) { + if (!walletKit) { + throw new Error('Wallet kit not initialized'); + } + + set({ isLoading: true, error: null }); + + try { + console.log( + 'Connecting to wallet:', + walletId, + 'on network:', + currentNetwork + ); + + // Special handling for Freighter - try direct API if kit doesn't work + if (walletId === 'freighter') { + try { + // First try the kit + walletKit.setWallet(walletId); + const addressResult = await walletKit.getAddress(); + console.log('Freighter connected via kit:', addressResult); + usingFreighterAPI = false; + } catch (kitError) { + console.log( + 'Freighter kit connection failed, trying direct API:', + kitError + ); + + // Try direct Freighter API as fallback + const directAvailable = await checkFreighterAvailability(); + if (!directAvailable) { throw new Error( - 'Freighter wallet is not connected. Please unlock your wallet and try again.' + 'Freighter is not available. Please install the Freighter extension.' ); } - const addressResult = await getAddress(); - if ( - !addressResult.address || - addressResult.address.trim() === '' - ) { - throw new Error( - 'No account selected in Freighter. Please select an account and try again.' + // Use direct Freighter API + await freighterSetAllowed(); + const address = await freighterGetAddress(); + console.log('Freighter connected via direct API:', address); + usingFreighterAPI = true; + + // Get network from Freighter + let networkResult; + try { + const freighterNetwork = await freighterGetNetwork(); + const networkStr = + typeof freighterNetwork === 'string' + ? freighterNetwork + : 'TESTNET'; + networkResult = { + network: networkStr === 'TESTNET' ? 'TESTNET' : 'PUBLIC', + networkPassphrase: + networkStr === 'TESTNET' + ? WalletNetwork.TESTNET + : WalletNetwork.PUBLIC, + }; + } catch (networkError) { + console.log( + 'Failed to get network from Freighter, using current:', + networkError ); + networkResult = { + network: currentNetwork === 'testnet' ? 'TESTNET' : 'PUBLIC', + networkPassphrase: + currentNetwork === 'testnet' + ? WalletNetwork.TESTNET + : WalletNetwork.PUBLIC, + }; } - const networkResult = await getNetwork(); const network: StellarNetwork = networkResult.network === 'TESTNET' ? 'testnet' : 'public'; + const capabilities = walletCapabilities.freighter; + + set({ + publicKey: + typeof address === 'string' ? address : String(address), + network, + isConnected: true, + isLoading: false, + error: null, + selectedWallet: walletId, + canSignTransaction: capabilities.canSignTransaction, + canSignMessage: capabilities.canSignMessage, + canSignAuthEntry: capabilities.canSignAuthEntry, + }); + return; + } + } - return { address: addressResult.address, network }; - })(); + // For other wallets, use the kit + walletKit.setWallet(walletId); + + // For some wallets, we need to handle the connection differently + let addressResult; + + try { + // Try to get address directly + addressResult = await walletKit.getAddress(); + console.log('Direct address result:', addressResult); + } catch (directError) { + console.log( + 'Direct connection failed, trying alternative method:', + directError + ); + + // For some wallets like Albedo, we might need to trigger a connection first + if (walletId === 'albedo') { + // Albedo might need a different approach + console.log('Attempting Albedo-specific connection...'); + } + + // Try again after a short delay + await new Promise(resolve => setTimeout(resolve, 1000)); + addressResult = await walletKit.getAddress(); + console.log('Retry address result:', addressResult); } - const result = await connectionPromise; + if (!addressResult || !addressResult.address) { + throw new Error('No public key received from wallet'); + } + + // Get current network - handle wallets that don't support getNetwork + let networkResult; + try { + networkResult = await walletKit.getNetwork(); + console.log('Network result:', networkResult); + } catch (networkError) { + console.log( + 'Failed to get network, using current network:', + networkError + ); + // Use current network if we can't get it from wallet + networkResult = { + network: currentNetwork === 'testnet' ? 'TESTNET' : 'PUBLIC', + networkPassphrase: + currentNetwork === 'testnet' + ? WalletNetwork.TESTNET + : WalletNetwork.PUBLIC, + }; + } + + const network: StellarNetwork = + networkResult.network === 'TESTNET' ? 'testnet' : 'public'; - cachedAddress = result.address; - cachedNetwork = result.network; + // Get wallet capabilities based on wallet type + const capabilities = walletCapabilities[ + walletId as keyof typeof walletCapabilities + ] || { + canSignTransaction: true, + canSignMessage: false, + canSignAuthEntry: false, + }; set({ - publicKey: result.address, - network: result.network, + publicKey: addressResult.address, + network, isConnected: true, isLoading: false, error: null, + selectedWallet: walletId, + canSignTransaction: capabilities.canSignTransaction, + canSignMessage: capabilities.canSignMessage, + canSignAuthEntry: capabilities.canSignAuthEntry, }); } catch (error) { - cachedAddress = null; - cachedNetwork = null; - connectionPromise = null; - + console.error('Failed to connect wallet:', error); set({ isLoading: false, error: @@ -110,43 +392,215 @@ export const useWalletStore = create()( ? error.message : 'Failed to connect wallet', }); + throw error; } }, disconnectWallet: () => { - cachedAddress = null; - cachedNetwork = null; - connectionPromise = null; + const { walletKit } = get(); + + if (walletKit && !usingFreighterAPI) { + walletKit.disconnect().catch(() => {}); + } + + usingFreighterAPI = false; set({ publicKey: null, - network: 'testnet', + network: currentNetwork, isConnected: false, + selectedWallet: null, + canSignTransaction: false, + canSignMessage: false, + canSignAuthEntry: false, error: null, }); }, - signXDR: async (xdr: string): Promise => { - const { isConnected: walletConnected, network } = get(); + switchNetwork: async (network: StellarNetwork) => { + console.log('Switching network from', currentNetwork, 'to', network); + + // Disconnect current wallet if connected + const { isConnected, selectedWallet } = get(); + if (isConnected && selectedWallet) { + get().disconnectWallet(); + } + + // Reinitialize wallet kit with new network + await get().initializeWalletKit(network); + + // If we were connected, try to reconnect with the new network + if (isConnected && selectedWallet) { + try { + await get().connectWallet(selectedWallet); + } catch (error) { + console.error('Failed to reconnect after network switch:', error); + // Don't throw here, just log the error + } + } + }, + + signTransaction: async (xdr: string): Promise => { + const { walletKit, isConnected, canSignTransaction, selectedWallet } = + get(); - if (!walletConnected) { + if (!isConnected) { throw new Error('Wallet not connected'); } + if (!canSignTransaction) { + throw new Error( + 'Current wallet does not support transaction signing' + ); + } + try { - const signedResult = await signTransaction(xdr, { - networkPassphrase: - network === 'testnet' - ? 'Test SDF Network ; September 2015' - : 'Public Global Stellar Network ; September 2015', - }); - return signedResult.signedTxXdr; + console.log( + 'Signing transaction with wallet:', + selectedWallet, + 'usingFreighterAPI:', + usingFreighterAPI + ); + + let result; + if (usingFreighterAPI && selectedWallet === 'freighter') { + // Use direct Freighter API + result = await freighterSignTransaction(xdr); + console.log( + 'Transaction signed successfully via direct Freighter API' + ); + return typeof result === 'string' + ? result + : result.signedTxXdr || String(result); + } else { + // Use wallet kit + result = await walletKit!.signTransaction(xdr); + console.log('Transaction signed successfully via wallet kit'); + return result.signedTxXdr; + } } catch (error) { + console.error('Transaction signing failed:', error); + + // Provide more specific error messages + let errorMessage = 'Failed to sign transaction'; + if (error && typeof error === 'object' && 'message' in error) { + const err = error as { message: string; code?: number }; + if (err.message.includes('Invalid transaction XDR')) { + errorMessage = + 'Invalid transaction format. Please provide a valid XDR transaction.'; + } else if (err.message.includes('Intent request is invalid')) { + errorMessage = + 'Transaction format is not supported by this wallet.'; + } else { + errorMessage = err.message; + } + } + + throw new Error(errorMessage); + } + }, + + signMessage: async (message: string): Promise => { + const { walletKit, isConnected, canSignMessage, selectedWallet } = + get(); + + if (!isConnected) { + throw new Error('Wallet not connected'); + } + + if (!canSignMessage) { throw new Error( - error instanceof Error - ? error.message - : 'Failed to sign transaction' + `Current wallet (${selectedWallet}) does not support message signing` + ); + } + + try { + console.log( + 'Signing message with wallet:', + selectedWallet, + 'usingFreighterAPI:', + usingFreighterAPI ); + + let result; + if (usingFreighterAPI && selectedWallet === 'freighter') { + // Direct Freighter API doesn't support message signing + throw new Error( + 'Message signing not available via direct Freighter API' + ); + } else { + // Use wallet kit + result = await walletKit!.signMessage(message); + console.log('Message signed successfully via wallet kit'); + return result.signedMessage; + } + } catch (error) { + console.error('Message signing failed:', error); + + // Provide more specific error messages + let errorMessage = 'Failed to sign message'; + if (error && typeof error === 'object' && 'message' in error) { + const err = error as { message: string; code?: number }; + if (err.message.includes('does not support')) { + errorMessage = `Message signing is not supported by ${selectedWallet}`; + } else { + errorMessage = err.message; + } + } + + throw new Error(errorMessage); + } + }, + + signAuthEntry: async (authEntry: string): Promise => { + const { walletKit, isConnected, canSignAuthEntry, selectedWallet } = + get(); + + if (!isConnected) { + throw new Error('Wallet not connected'); + } + + if (!canSignAuthEntry) { + throw new Error( + `Current wallet (${selectedWallet}) does not support auth entry signing` + ); + } + + try { + console.log( + 'Signing auth entry with wallet:', + selectedWallet, + 'usingFreighterAPI:', + usingFreighterAPI + ); + + let result; + if (usingFreighterAPI && selectedWallet === 'freighter') { + // Direct Freighter API doesn't support auth entry signing + throw new Error( + 'Auth entry signing not available via direct Freighter API' + ); + } else { + // Use wallet kit + result = await walletKit!.signAuthEntry(authEntry); + console.log('Auth entry signed successfully via wallet kit'); + return result.signedAuthEntry; + } + } catch (error) { + console.error('Auth entry signing failed:', error); + + // Provide more specific error messages + let errorMessage = 'Failed to sign auth entry'; + if (error && typeof error === 'object' && 'message' in error) { + const err = error as { message: string; code?: number }; + if (err.message.includes('does not support')) { + errorMessage = `Auth entry signing is not supported by ${selectedWallet}`; + } else { + errorMessage = err.message; + } + } + + throw new Error(errorMessage); } }, @@ -157,20 +611,34 @@ export const useWalletStore = create()( clearError: () => { set({ error: null }); }, + + getWalletInfo: () => { + const { publicKey, network } = get(); + + if (!publicKey) { + return null; + } + + return { + address: publicKey, + network: network === 'testnet' ? 'TESTNET' : 'PUBLIC', + }; + }, }), { - name: 'improved-wallet-storage', + name: 'stellar-wallets-kit-storage', partialize: state => ({ publicKey: state.publicKey, network: state.network, isConnected: state.isConnected, + selectedWallet: state.selectedWallet, }), } ) ); export function useWalletInfo() { - const { publicKey, network, isConnected } = useWalletStore(); + const { publicKey, network, isConnected, selectedWallet } = useWalletStore(); if (!isConnected || !publicKey) { return null; @@ -180,17 +648,19 @@ export function useWalletInfo() { address: publicKey, displayName: formatPublicKey(publicKey), network, + walletType: selectedWallet, }; } export function useAutoReconnect() { - const { isConnected, publicKey, connectWallet } = useWalletStore(); + const { isConnected, publicKey, selectedWallet, connectWallet } = + useWalletStore(); useEffect(() => { - if (publicKey && !isConnected) { - connectWallet().catch(() => {}); + if (publicKey && selectedWallet && !isConnected) { + connectWallet(selectedWallet).catch(() => {}); } - }, [publicKey, isConnected, connectWallet]); + }, [publicKey, selectedWallet, isConnected, connectWallet]); } export function useWalletConnection() { @@ -198,12 +668,12 @@ export function useWalletConnection() { const [error, setError] = useState(null); const { connectWallet, clearError } = useWalletStore(); - const connect = async () => { + const connect = async (walletId: string) => { setIsConnecting(true); setError(null); try { - await connectWallet(); + await connectWallet(walletId); } catch (err) { const errorMessage = err instanceof Error ? err.message : 'Failed to connect wallet'; @@ -223,3 +693,45 @@ export function useWalletConnection() { }, }; } + +export function useNetworkSwitcher() { + const { network, switchNetwork, isLoading } = useWalletStore(); + + const switchToNetwork = async (newNetwork: StellarNetwork) => { + if (newNetwork === network) return; + + try { + await switchNetwork(newNetwork); + } catch (error) { + console.error('Failed to switch network:', error); + } + }; + + return { + currentNetwork: network, + switchToNetwork, + isLoading, + }; +} + +export function useWalletSigning() { + const { + signTransaction, + signMessage, + signAuthEntry, + canSignTransaction, + canSignMessage, + canSignAuthEntry, + isConnected, + } = useWalletStore(); + + return { + signTransaction, + signMessage, + signAuthEntry, + canSignTransaction, + canSignMessage, + canSignAuthEntry, + isConnected, + }; +} diff --git a/lib/api/api.ts b/lib/api/api.ts index dde23c177..e118291dc 100644 --- a/lib/api/api.ts +++ b/lib/api/api.ts @@ -1,5 +1,6 @@ import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios'; import Cookies from 'js-cookie'; +import { useAuthStore } from '@/lib/stores/auth-store'; const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL; if (!API_BASE_URL) { @@ -23,8 +24,42 @@ export interface ApiError { export interface RequestConfig { headers?: Record; timeout?: number; + skipAuthRefresh?: boolean; } +// Token refresh function +const refreshAccessToken = async (): Promise => { + try { + const refreshToken = Cookies.get('refreshToken'); + if (!refreshToken) { + return null; + } + + const response = await axios.post( + `${API_BASE_URL}/auth/refresh`, + { refreshToken }, + { + headers: { + 'Content-Type': 'application/json', + }, + } + ); + + const { accessToken, refreshToken: newRefreshToken } = response.data.data; + + // Update tokens in store and cookies + const authStore = useAuthStore.getState(); + authStore.setTokens(accessToken, newRefreshToken); + + return accessToken; + } catch { + // If refresh fails, clear auth data + const authStore = useAuthStore.getState(); + authStore.clearAuth(); + return null; + } +}; + const createClientApi = (): AxiosInstance => { const instance = axios.create({ baseURL: API_BASE_URL, @@ -36,6 +71,7 @@ const createClientApi = (): AxiosInstance => { withCredentials: true, }); + // Request interceptor instance.interceptors.request.use( config => { config.withCredentials = true; @@ -53,11 +89,70 @@ const createClientApi = (): AxiosInstance => { } ); + // Response interceptor with automatic token refresh instance.interceptors.response.use( (response: AxiosResponse) => { return response; }, - error => { + async error => { + const originalRequest = error.config; + + // Handle 401 errors with automatic token refresh + if (error.response?.status === 401 && !originalRequest._retry) { + originalRequest._retry = true; + + // Skip auth refresh if explicitly requested + if (originalRequest.skipAuthRefresh) { + const authStore = useAuthStore.getState(); + authStore.clearAuth(); + return Promise.reject({ + message: 'Authentication required', + status: 401, + code: 'UNAUTHORIZED', + }); + } + + try { + const newAccessToken = await refreshAccessToken(); + + if (newAccessToken) { + // Retry the original request with new token + originalRequest.headers.Authorization = `Bearer ${newAccessToken}`; + return instance(originalRequest); + } else { + // Refresh failed, redirect to login + const authStore = useAuthStore.getState(); + authStore.clearAuth(); + + // Only redirect if we're on the client side + if (typeof window !== 'undefined') { + window.location.href = '/auth/signin'; + } + + return Promise.reject({ + message: 'Session expired. Please login again.', + status: 401, + code: 'SESSION_EXPIRED', + }); + } + } catch { + const authStore = useAuthStore.getState(); + authStore.clearAuth(); + + // Only redirect if we're on the client side + if (typeof window !== 'undefined') { + window.location.href = '/auth/signin'; + } + + return Promise.reject({ + message: 'Session expired. Please login again.', + status: 401, + code: 'SESSION_EXPIRED', + }); + } + } + + // Handle other errors if (error.response) { const errorData = error.response.data; const customError: ApiError = { @@ -188,4 +283,4 @@ export const api = { ): Promise> => clientApi.delete(url, config), }; -export default clientApi; +export default axiosInstance; diff --git a/lib/api/auth.ts b/lib/api/auth.ts index b04fcfba2..237288401 100644 --- a/lib/api/auth.ts +++ b/lib/api/auth.ts @@ -19,7 +19,7 @@ import { ResetPasswordRequest, ResetPasswordResponse, } from '@/lib/api/types'; -import Cookies from 'js-cookie'; +import { useAuthStore } from '@/lib/stores/auth-store'; export const register = async ( data: RegisterRequest @@ -35,18 +35,28 @@ export const register = async ( }; export const login = async (data: LoginRequest): Promise => { - const res = await api.post<{ - success: boolean; - data: LoginResponse; - message: string; - timestamp: string; - path: string; - }>('/auth/login', data); - if (res.data.data.accessToken) { - Cookies.set('accessToken', res.data.data.accessToken); - Cookies.set('refreshToken', res.data.data.refreshToken || ''); + try { + const res = await api.post<{ + success: boolean; + data: LoginResponse; + message: string; + timestamp: string; + path: string; + }>('/auth/login', data); + + const loginData = res.data.data; + + if (loginData.accessToken) { + // Use the auth store to handle login + const authStore = useAuthStore.getState(); + await authStore.login(loginData.accessToken, loginData.refreshToken); + } + + return loginData; + } catch (error) { + // Re-throw the error for the component to handle + throw error; } - return res.data.data; }; export const githubAuth = async ( @@ -78,17 +88,29 @@ export const getMe = async (token?: string): Promise => { }; export const logout = async (token?: string): Promise => { - const config = token - ? { headers: { Authorization: `Bearer ${token}` } } - : undefined; - const res = await api.post<{ - success: boolean; - data: LogoutResponse; - message: string; - timestamp: string; - path: string; - }>('/auth/logout', undefined, config); - return res.data.data; + try { + const config = token + ? { headers: { Authorization: `Bearer ${token}` } } + : undefined; + const res = await api.post<{ + success: boolean; + data: LogoutResponse; + message: string; + timestamp: string; + path: string; + }>('/auth/logout', undefined, config); + + // Use the auth store to handle logout + const authStore = useAuthStore.getState(); + await authStore.logout(); + + return res.data.data; + } catch (error) { + // Even if the API call fails, clear the local auth state + const authStore = useAuthStore.getState(); + authStore.clearAuth(); + throw error; + } }; export const verifyOtp = async ( @@ -142,3 +164,33 @@ export const resetPassword = async ( }>('/auth/reset-password', data); return res.data.data; }; + +// Enhanced auth utilities +export const refreshUserData = async (): Promise => { + const authStore = useAuthStore.getState(); + await authStore.refreshUser(); +}; + +export const checkAuthStatus = async (): Promise => { + try { + const authStore = useAuthStore.getState(); + const { accessToken, isAuthenticated } = authStore; + + if (!accessToken || !isAuthenticated) { + return false; + } + + // Try to refresh user data to verify token is still valid + await authStore.refreshUser(); + return true; + } catch { + return false; + } +}; + +export const getAuthHeaders = (): Record => { + const authStore = useAuthStore.getState(); + const { accessToken } = authStore; + + return accessToken ? { Authorization: `Bearer ${accessToken}` } : {}; +}; diff --git a/lib/auth/server-auth.ts b/lib/auth/server-auth.ts new file mode 100644 index 000000000..f50499c4f --- /dev/null +++ b/lib/auth/server-auth.ts @@ -0,0 +1,74 @@ +import { cookies } from 'next/headers'; +import { redirect } from 'next/navigation'; +import { getMe } from '@/lib/api/auth'; + +export interface ServerUser { + id: string; + email: string; + name: string | null; + image: string | null; + role: 'USER' | 'ADMIN'; + isVerified?: boolean; + profile?: { + firstName?: string; + lastName?: string; + avatar?: string; + }; +} + +export async function getServerUser(): Promise { + try { + const cookieStore = await cookies(); + const accessToken = cookieStore.get('accessToken')?.value; + + if (!accessToken) { + return null; + } + + const user = await getMe(accessToken); + + return { + id: (user._id || user.id) as string, + email: user.email as string, + name: (user.profile?.firstName || user.name) as string | null, + image: (user.profile?.avatar || user.image) as string | null, + role: user.roles?.[0] === 'ADMIN' ? 'ADMIN' : 'USER', + isVerified: user.isVerified, + profile: user.profile, + }; + } catch { + // Silently handle auth errors + return null; + } +} + +export async function requireServerAuth(): Promise { + const user = await getServerUser(); + + if (!user) { + redirect('/auth/signin'); + } + + return user; +} + +export async function getServerAuthHeaders(): Promise> { + const cookieStore = await cookies(); + const accessToken = cookieStore.get('accessToken')?.value; + + return accessToken ? { Authorization: `Bearer ${accessToken}` } : {}; +} + +export async function isServerAuthenticated(): Promise { + const user = await getServerUser(); + return !!user; +} + +// Utility for protected server components +export async function withServerAuth( + fn: (user: ServerUser, ...args: T) => Promise, + ...args: T +): Promise { + const user = await requireServerAuth(); + return fn(user, ...args); +} diff --git a/lib/motion.ts b/lib/motion.ts new file mode 100644 index 000000000..e22632993 --- /dev/null +++ b/lib/motion.ts @@ -0,0 +1,140 @@ +import { Variants } from 'framer-motion'; + +// Common animation variants that match the design aesthetic +export const fadeInUp: Variants = { + hidden: { opacity: 0, y: 20 }, + visible: { + opacity: 1, + y: 0, + transition: { + duration: 0.4, + ease: [0.25, 0.46, 0.45, 0.94], + }, + }, +}; + +export const fadeIn: Variants = { + hidden: { opacity: 0 }, + visible: { + opacity: 1, + transition: { + duration: 0.3, + ease: [0.25, 0.46, 0.45, 0.94], + }, + }, +}; + +export const scaleIn: Variants = { + hidden: { opacity: 0, scale: 0.95 }, + visible: { + opacity: 1, + scale: 1, + transition: { + duration: 0.3, + ease: [0.25, 0.46, 0.45, 0.94], + }, + }, +}; + +export const slideInFromLeft: Variants = { + hidden: { opacity: 0, x: -20 }, + visible: { + opacity: 1, + x: 0, + transition: { + duration: 0.4, + ease: [0.25, 0.46, 0.45, 0.94], + }, + }, +}; + +export const slideInFromRight: Variants = { + hidden: { opacity: 0, x: 20 }, + visible: { + opacity: 1, + x: 0, + transition: { + duration: 0.4, + ease: [0.25, 0.46, 0.45, 0.94], + }, + }, +}; + +export const staggerContainer: Variants = { + hidden: { opacity: 0 }, + visible: { + opacity: 1, + transition: { + staggerChildren: 0.1, + delayChildren: 0.1, + }, + }, +}; + +export const buttonHover: Variants = { + hover: { + scale: 1.02, + transition: { + duration: 0.2, + ease: [0.25, 0.46, 0.45, 0.94], + }, + }, + tap: { + scale: 0.98, + transition: { + duration: 0.1, + }, + }, +}; + +export const cardHover: Variants = { + hover: { + y: -2, + transition: { + duration: 0.2, + ease: [0.25, 0.46, 0.45, 0.94], + }, + }, +}; + +export const iconSpin: Variants = { + hover: { + rotate: 360, + transition: { + duration: 0.6, + ease: 'easeInOut', + }, + }, +}; + +// Container variants for staggered animations +export const containerVariants = { + hidden: { opacity: 0 }, + visible: { + opacity: 1, + transition: { + staggerChildren: 0.1, + delayChildren: 0.2, + }, + }, +}; + +// Page transition variants +export const pageTransition: Variants = { + initial: { opacity: 0, y: 20 }, + animate: { + opacity: 1, + y: 0, + transition: { + duration: 0.4, + ease: [0.25, 0.46, 0.45, 0.94], + }, + }, + exit: { + opacity: 0, + y: -20, + transition: { + duration: 0.3, + }, + }, +}; diff --git a/lib/stores/auth-store.ts b/lib/stores/auth-store.ts new file mode 100644 index 000000000..4027bd52b --- /dev/null +++ b/lib/stores/auth-store.ts @@ -0,0 +1,234 @@ +import { create } from 'zustand'; +import { persist, createJSONStorage } from 'zustand/middleware'; +import { getMe, logout } from '@/lib/api/auth'; +import Cookies from 'js-cookie'; + +export interface User { + id: string; + email: string; + name: string | null; + image: string | null; + role: 'USER' | 'ADMIN'; + isVerified?: boolean; + profile?: { + firstName?: string; + lastName?: string; + avatar?: string; + }; +} + +export interface AuthState { + // State + user: User | null; + accessToken: string | null; + refreshToken: string | null; + isAuthenticated: boolean; + isLoading: boolean; + error: string | null; + + // Actions + setUser: (user: User | null) => void; + setTokens: (accessToken: string | null, refreshToken?: string | null) => void; + setLoading: (loading: boolean) => void; + setError: (error: string | null) => void; + login: (accessToken: string, refreshToken?: string) => Promise; + logout: () => Promise; + refreshUser: () => Promise; + clearAuth: () => void; + updateUser: (updates: Partial) => void; +} + +export const useAuthStore = create()( + persist( + (set, get) => ({ + // Initial state + user: null, + accessToken: null, + refreshToken: null, + isAuthenticated: false, + isLoading: false, + error: null, + + // Actions + setUser: user => { + set({ user, isAuthenticated: !!user }); + }, + + setTokens: (accessToken, refreshToken) => { + set({ accessToken, refreshToken }); + + // Update cookies + if (accessToken) { + Cookies.set('accessToken', accessToken); + } else { + Cookies.remove('accessToken'); + } + + if (refreshToken) { + Cookies.set('refreshToken', refreshToken); + } else { + Cookies.remove('refreshToken'); + } + }, + + setLoading: isLoading => { + set({ isLoading }); + }, + + setError: error => { + set({ error }); + }, + + login: async (accessToken, refreshToken) => { + try { + set({ isLoading: true, error: null }); + + // Set tokens first + get().setTokens(accessToken, refreshToken); + + // Fetch user data + const user = await getMe(accessToken); + + // Transform user data to match our User interface + const transformedUser: User = { + id: (user._id || user.id) as string, + email: user.email as string, + name: (user.profile?.firstName || user.name) as string | null, + image: (user.profile?.avatar || user.image) as string | null, + role: (user.roles?.[0] === 'ADMIN' ? 'ADMIN' : 'USER') as + | 'USER' + | 'ADMIN', + isVerified: user.isVerified as boolean | undefined, + profile: user.profile as User['profile'], + }; + + set({ + user: transformedUser, + isAuthenticated: true, + isLoading: false, + }); + } catch (error) { + set({ + error: error instanceof Error ? error.message : 'Login failed', + isLoading: false, + }); + throw error; + } + }, + + logout: async () => { + try { + set({ isLoading: true }); + + const { accessToken } = get(); + + // Call logout API if we have a token + if (accessToken) { + try { + await logout(accessToken); + } catch { + // Don't throw error for logout API failure + // Silently handle logout API failures + } + } + + // Clear all auth data + get().clearAuth(); + set({ isLoading: false }); + } catch (error) { + set({ + error: error instanceof Error ? error.message : 'Logout failed', + isLoading: false, + }); + throw error; + } + }, + + refreshUser: async () => { + try { + const { accessToken } = get(); + + if (!accessToken) { + throw new Error('No access token available'); + } + + set({ isLoading: true, error: null }); + + const user = await getMe(accessToken); + + const transformedUser: User = { + id: (user._id || user.id) as string, + email: user.email as string, + name: (user.profile?.firstName || user.name) as string | null, + image: (user.profile?.avatar || user.image) as string | null, + role: (user.roles?.[0] === 'ADMIN' ? 'ADMIN' : 'USER') as + | 'USER' + | 'ADMIN', + isVerified: user.isVerified as boolean | undefined, + profile: user.profile as User['profile'], + }; + + set({ + user: transformedUser, + isAuthenticated: true, + isLoading: false, + }); + } catch (error) { + set({ + error: + error instanceof Error ? error.message : 'Failed to refresh user', + isLoading: false, + }); + + // If refresh fails, clear auth data + if (error instanceof Error && error.message.includes('401')) { + get().clearAuth(); + } + + throw error; + } + }, + + clearAuth: () => { + set({ + user: null, + accessToken: null, + refreshToken: null, + isAuthenticated: false, + isLoading: false, + error: null, + }); + + // Clear cookies + Cookies.remove('accessToken'); + Cookies.remove('refreshToken'); + }, + + updateUser: updates => { + const { user } = get(); + if (user) { + set({ user: { ...user, ...updates } }); + } + }, + }), + { + name: 'auth-storage', + storage: createJSONStorage(() => localStorage), + partialize: state => ({ + user: state.user, + accessToken: state.accessToken, + refreshToken: state.refreshToken, + isAuthenticated: state.isAuthenticated, + }), + onRehydrateStorage: () => state => { + // Rehydrate cookies from localStorage on app start + if (state?.accessToken) { + Cookies.set('accessToken', state.accessToken); + } + if (state?.refreshToken) { + Cookies.set('refreshToken', state.refreshToken); + } + }, + } + ) +); diff --git a/lib/wallet-utils.ts b/lib/wallet-utils.ts new file mode 100644 index 000000000..5dce7c4a3 --- /dev/null +++ b/lib/wallet-utils.ts @@ -0,0 +1,365 @@ +/** + * Wallet Utilities + * Comprehensive helper functions for Stellar wallet operations + */ + +import { StellarNetwork } from '@/hooks/use-wallet'; + +// Environment variables +const STELLAR_NETWORK = process.env.NEXT_PUBLIC_STELLAR_NETWORK || 'testnet'; +const APP_URL = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000'; +const WALLET_CONNECT_PROJECT_ID = + process.env.NEXT_PUBLIC_WALLET_CONNECT_PROJECT_ID; + +// Network configurations +export const NETWORKS = { + testnet: { + name: 'Testnet', + horizon: 'https://horizon-testnet.stellar.org', + networkPassphrase: 'Test SDF Network ; September 2015', + explorer: 'https://stellar.expert/explorer/testnet', + color: '#fbbf24', // yellow + }, + public: { + name: 'Public', + horizon: 'https://horizon.stellar.org', + networkPassphrase: 'Public Global Stellar Network ; September 2015', + explorer: 'https://stellar.expert/explorer/public', + color: '#3b82f6', // blue + }, +} as const; + +export type NetworkType = keyof typeof NETWORKS; + +/** + * Format a Stellar address for display + * @param address - The full Stellar address + * @param length - Number of characters to show from start and end + * @returns Formatted address like "GABC...XYZ" + */ +export function formatAddress(address: string, length: number = 4): string { + if (!address || address.length < length * 2 + 3) { + return address; + } + + const start = address.slice(0, length); + const end = address.slice(-length); + return `${start}...${end}`; +} + +/** + * Validate a Stellar address format + * @param address - The address to validate + * @returns True if valid Stellar address format + */ +export function validateAddress(address: string): boolean { + if (!address || typeof address !== 'string') { + return false; + } + + // Stellar addresses are 56 characters long and start with G, M, S, or T + const stellarAddressRegex = /^[GMS][A-Z2-7]{55}$/; + return stellarAddressRegex.test(address); +} + +/** + * Get explorer URL for an address + * @param address - The Stellar address + * @param network - The network (testnet or public) + * @returns Full explorer URL + */ +export function getExplorerUrl( + address: string, + network: NetworkType = 'testnet' +): string { + if (!validateAddress(address)) { + throw new Error('Invalid Stellar address'); + } + + const networkConfig = NETWORKS[network]; + return `${networkConfig.explorer}/account/${address}`; +} + +/** + * Get transaction explorer URL + * @param txHash - The transaction hash + * @param network - The network (testnet or public) + * @returns Full transaction explorer URL + */ +export function getTransactionExplorerUrl( + txHash: string, + network: NetworkType = 'testnet' +): string { + const networkConfig = NETWORKS[network]; + return `${networkConfig.explorer}/tx/${txHash}`; +} + +/** + * Get asset explorer URL + * @param assetCode - The asset code + * @param assetIssuer - The asset issuer address + * @param network - The network (testnet or public) + * @returns Full asset explorer URL + */ +export function getAssetExplorerUrl( + assetCode: string, + assetIssuer: string, + network: NetworkType = 'testnet' +): string { + const networkConfig = NETWORKS[network]; + return `${networkConfig.explorer}/asset/${assetCode}-${assetIssuer}`; +} + +/** + * Get network configuration + * @param network - The network type + * @returns Network configuration object + */ +export function getNetworkConfig(network: NetworkType) { + return NETWORKS[network]; +} + +/** + * Get current network from environment + * @returns Current network type + */ +export function getCurrentNetwork(): NetworkType { + return STELLAR_NETWORK as NetworkType; +} + +/** + * Get horizon URL for current network + * @param network - The network type + * @returns Horizon server URL + */ +export function getHorizonUrl(network: NetworkType = 'testnet'): string { + return NETWORKS[network].horizon; +} + +/** + * Get network passphrase + * @param network - The network type + * @returns Network passphrase + */ +export function getNetworkPassphrase(network: NetworkType = 'testnet'): string { + return NETWORKS[network].networkPassphrase; +} + +/** + * Get network color + * @param network - The network type + * @returns Network color hex code + */ +export function getNetworkColor(network: NetworkType = 'testnet'): string { + return NETWORKS[network].color; +} + +/** + * Convert network type to display name + * @param network - The network type + * @returns Display name + */ +export function getNetworkDisplayName(network: NetworkType): string { + return NETWORKS[network].name; +} + +/** + * Check if address is valid and format it + * @param address - The address to validate and format + * @returns Object with validation result and formatted address + */ +export function validateAndFormatAddress(address: string) { + const isValid = validateAddress(address); + const formatted = isValid ? formatAddress(address) : address; + + return { + isValid, + formatted, + full: address, + }; +} + +/** + * Generate wallet connection metadata + * @returns Wallet connection metadata object + */ +export function getWalletMetadata() { + return { + name: 'Boundless', + description: 'Stellar-based application', + url: APP_URL, + icons: [`${APP_URL}/logo.svg`], + }; +} + +/** + * Get WalletConnect configuration + * @returns WalletConnect config object + */ +export function getWalletConnectConfig() { + if (!WALLET_CONNECT_PROJECT_ID) { + throw new Error('WALLET_CONNECT_PROJECT_ID is required for WalletConnect'); + } + + return { + projectId: WALLET_CONNECT_PROJECT_ID, + metadata: getWalletMetadata(), + }; +} + +/** + * Format XDR for display + * @param xdr - The XDR string + * @param maxLength - Maximum length to show + * @returns Formatted XDR string + */ +export function formatXDR(xdr: string, maxLength: number = 50): string { + if (!xdr || xdr.length <= maxLength) { + return xdr; + } + + return `${xdr.slice(0, maxLength)}...`; +} + +/** + * Validate XDR format + * @param xdr - The XDR string to validate + * @returns True if valid XDR format + */ +export function validateXDR(xdr: string): boolean { + if (!xdr || typeof xdr !== 'string') { + return false; + } + + // Basic XDR validation - should be base64 encoded + try { + // Check if it's valid base64 + atob(xdr); + return true; + } catch { + return false; + } +} + +/** + * Get wallet display name from wallet ID + * @param walletId - The wallet identifier + * @returns Display name for the wallet + */ +export function getWalletDisplayName(walletId: string): string { + const walletNames: Record = { + freighter: 'Freighter', + albedo: 'Albedo', + rabet: 'Rabet', + xbull: 'xBull', + lobstr: 'Lobstr', + hana: 'Hana', + 'hot-wallet': 'HOT Wallet', + 'wallet-connect': 'WalletConnect', + }; + + return walletNames[walletId] || walletId; +} + +/** + * Get wallet icon path + * @param walletId - The wallet identifier + * @returns Path to wallet icon + */ +export function getWalletIcon(walletId: string): string { + return `/wallets/${walletId}.svg`; +} + +/** + * Check if wallet supports specific feature + * @param walletId - The wallet identifier + * @param feature - The feature to check + * @returns True if wallet supports the feature + */ +export function walletSupportsFeature( + walletId: string, + feature: 'transaction' | 'message' | 'auth-entry' +): boolean { + const capabilities: Record> = { + freighter: { + transaction: true, + message: true, + 'auth-entry': true, + }, + albedo: { + transaction: true, + message: false, + 'auth-entry': false, + }, + rabet: { + transaction: true, + message: true, + 'auth-entry': true, + }, + xbull: { + transaction: true, + message: true, + 'auth-entry': true, + }, + lobstr: { + transaction: true, + message: false, + 'auth-entry': false, + }, + hana: { + transaction: true, + message: false, + 'auth-entry': false, + }, + 'hot-wallet': { + transaction: true, + message: true, + 'auth-entry': true, + }, + 'wallet-connect': { + transaction: true, + message: true, + 'auth-entry': true, + }, + }; + + return capabilities[walletId]?.[feature] || false; +} + +/** + * Generate a unique session ID for wallet connections + * @returns Unique session ID + */ +export function generateSessionId(): string { + return `session_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; +} + +/** + * Parse network from string + * @param network - Network string + * @returns Network type or null if invalid + */ +export function parseNetwork(network: string): NetworkType | null { + if (network === 'testnet' || network === 'public') { + return network as NetworkType; + } + return null; +} + +/** + * Convert StellarNetwork type to NetworkType + * @param network - StellarNetwork type value + * @returns NetworkType + */ +export function stellarNetworkToType(network: StellarNetwork): NetworkType { + return network === 'testnet' ? 'testnet' : 'public'; +} + +/** + * Convert NetworkType to StellarNetwork type + * @param network - NetworkType + * @returns StellarNetwork type value + */ +export function networkTypeToStellar(network: NetworkType): StellarNetwork { + return network === 'testnet' ? 'testnet' : 'public'; +} diff --git a/middleware.ts b/middleware.ts index c979e881d..96edbf15a 100644 --- a/middleware.ts +++ b/middleware.ts @@ -1 +1,59 @@ -export { auth as middleware } from '@/auth'; +import { NextResponse } from 'next/server'; +import type { NextRequest } from 'next/server'; + +// Define protected routes +const protectedRoutes = ['/dashboard', '/user', '/projects', '/admin']; + +// Define auth routes (routes that should redirect to dashboard if already authenticated) +const authRoutes = ['/auth/signin', '/auth/signup', '/auth/forgot-password']; + +// Define public routes that don't need auth +export const publicRoutes = ['/', '/auth/verify-email', '/auth/reset-password']; + +export function middleware(request: NextRequest) { + const { pathname } = request.nextUrl; + const accessToken = request.cookies.get('accessToken')?.value; + const isAuthenticated = !!accessToken; + + // Check if the route is protected + const isProtectedRoute = protectedRoutes.some(route => + pathname.startsWith(route) + ); + + // Check if the route is an auth route + const isAuthRoute = authRoutes.some(route => pathname.startsWith(route)); + + // Check if the route is public + // const isPublicRoute = publicRoutes.some(route => + // pathname.startsWith(route) + // ); + + // Redirect authenticated users away from auth routes + if (isAuthRoute && isAuthenticated) { + return NextResponse.redirect(new URL('/dashboard', request.url)); + } + + // Redirect unauthenticated users to signin for protected routes + if (isProtectedRoute && !isAuthenticated) { + const signinUrl = new URL('/auth/signin', request.url); + signinUrl.searchParams.set('callbackUrl', pathname); + return NextResponse.redirect(signinUrl); + } + + // Allow all other requests to proceed + return NextResponse.next(); +} + +export const config = { + matcher: [ + /* + * Match all request paths except for the ones starting with: + * - api (API routes) + * - _next/static (static files) + * - _next/image (image optimization files) + * - favicon.ico (favicon file) + * - public folder + */ + '/((?!api|_next/static|_next/image|favicon.ico|public).*)', + ], +}; diff --git a/next.config.ts b/next.config.ts index b670ab9ae..416b60e29 100644 --- a/next.config.ts +++ b/next.config.ts @@ -8,6 +8,14 @@ const nextConfig: NextConfig = { protocol: 'https', hostname: 'github.com', }, + { + protocol: 'https', + hostname: 'stellar.creit.tech', + }, + { + protocol: 'https', + hostname: 'storage.herewallet.app', + }, ], }, }; diff --git a/public/albedo-square.svg b/public/albedo-square.svg new file mode 100644 index 000000000..81beed1e9 --- /dev/null +++ b/public/albedo-square.svg @@ -0,0 +1,7 @@ + + + + diff --git a/public/albedo.svg b/public/albedo.svg new file mode 100644 index 000000000..f9f0df667 --- /dev/null +++ b/public/albedo.svg @@ -0,0 +1,20 @@ + + + + + + + + + + + + diff --git a/public/wallets/albedo.svg b/public/wallets/albedo.svg new file mode 100644 index 000000000..fc7a05c8e --- /dev/null +++ b/public/wallets/albedo.svg @@ -0,0 +1,4 @@ + + + + diff --git a/public/wallets/freighter.svg b/public/wallets/freighter.svg new file mode 100644 index 000000000..b9d80ddc1 --- /dev/null +++ b/public/wallets/freighter.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/public/wallets/hana.svg b/public/wallets/hana.svg new file mode 100644 index 000000000..1a3f5c5a1 --- /dev/null +++ b/public/wallets/hana.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/public/wallets/hot-wallet.svg b/public/wallets/hot-wallet.svg new file mode 100644 index 000000000..dd06c7cfc --- /dev/null +++ b/public/wallets/hot-wallet.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/public/wallets/ledger.svg b/public/wallets/ledger.svg new file mode 100644 index 000000000..073f88de3 --- /dev/null +++ b/public/wallets/ledger.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/public/wallets/lobstr.svg b/public/wallets/lobstr.svg new file mode 100644 index 000000000..063e565e0 --- /dev/null +++ b/public/wallets/lobstr.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/public/wallets/rabet.svg b/public/wallets/rabet.svg new file mode 100644 index 000000000..9dbbe8dfa --- /dev/null +++ b/public/wallets/rabet.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/public/wallets/trezor.svg b/public/wallets/trezor.svg new file mode 100644 index 000000000..c561266ea --- /dev/null +++ b/public/wallets/trezor.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/public/wallets/wallet-connect.svg b/public/wallets/wallet-connect.svg new file mode 100644 index 000000000..15a57e092 --- /dev/null +++ b/public/wallets/wallet-connect.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/public/wallets/xbull.svg b/public/wallets/xbull.svg new file mode 100644 index 000000000..799e8f45a --- /dev/null +++ b/public/wallets/xbull.svg @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + +