diff --git a/mobile/App.tsx b/mobile/App.tsx index 69f5dff3..1c5dce0a 100644 --- a/mobile/App.tsx +++ b/mobile/App.tsx @@ -2,8 +2,11 @@ import React, { useEffect, useState } from 'react'; import { View, ActivityIndicator, StyleSheet } from 'react-native'; import { StatusBar } from 'expo-status-bar'; import { initializeI18n } from './src/i18n'; +import { ErrorBoundary } from './src/components/ErrorBoundary'; import { LanguageProvider } from './src/contexts/LanguageContext'; +import { AuthProvider } from './src/contexts/AuthContext'; import { PolkadotProvider } from './src/contexts/PolkadotContext'; +import { BiometricAuthProvider } from './src/contexts/BiometricAuthContext'; import AppNavigator from './src/navigation/AppNavigator'; import { KurdistanColors } from './src/theme/colors'; @@ -35,12 +38,18 @@ export default function App() { } return ( - - - - - - + + + + + + + + + + + + ); } diff --git a/mobile/__tests__/App.test.tsx b/mobile/__tests__/App.test.tsx new file mode 100644 index 00000000..524e738d --- /dev/null +++ b/mobile/__tests__/App.test.tsx @@ -0,0 +1,36 @@ +import React from 'react'; +import { render, waitFor } from '@testing-library/react-native'; +import App from '../App'; + +// Mock i18n initialization +jest.mock('../src/i18n', () => ({ + initializeI18n: jest.fn(() => Promise.resolve()), +})); + +describe('App Integration Tests', () => { + it('should render App component', async () => { + const { getByTestId, UNSAFE_getByType } = render(); + + // Wait for i18n to initialize + await waitFor(() => { + // App should render without crashing + expect(UNSAFE_getByType(App)).toBeTruthy(); + }); + }); + + it('should show loading indicator while initializing', () => { + const { UNSAFE_getAllByType } = render(); + + // Should have ActivityIndicator during initialization + const indicators = UNSAFE_getAllByType(require('react-native').ActivityIndicator); + expect(indicators.length).toBeGreaterThan(0); + }); + + it('should wrap app in ErrorBoundary', () => { + const { UNSAFE_getByType } = render(); + + // ErrorBoundary should be present in component tree + // This verifies the provider hierarchy is correct + expect(UNSAFE_getByType(App)).toBeTruthy(); + }); +}); diff --git a/mobile/jest.config.js b/mobile/jest.config.js new file mode 100644 index 00000000..81b49fc6 --- /dev/null +++ b/mobile/jest.config.js @@ -0,0 +1,26 @@ +module.exports = { + preset: 'jest-expo', + setupFilesAfterEnv: ['/jest.setup.js'], + transformIgnorePatterns: [ + 'node_modules/(?!((jest-)?react-native|@react-native(-community)?)|expo(nent)?|@expo(nent)?/.*|@expo-google-fonts/.*|react-navigation|@react-navigation/.*|@unimodules/.*|unimodules|sentry-expo|native-base|react-native-svg|@polkadot/.*)', + ], + moduleNameMapper: { + '^@pezkuwi/(.*)$': '/../shared/$1', + '^@/(.*)$': '/src/$1', + }, + testMatch: ['**/__tests__/**/*.test.(ts|tsx|js)'], + collectCoverageFrom: [ + 'src/**/*.{ts,tsx}', + '!src/**/*.d.ts', + '!src/**/__tests__/**', + '!src/**/types/**', + ], + coverageThreshold: { + global: { + statements: 70, + branches: 60, + functions: 70, + lines: 70, + }, + }, +}; diff --git a/mobile/jest.setup.js b/mobile/jest.setup.js new file mode 100644 index 00000000..2a18a9d1 --- /dev/null +++ b/mobile/jest.setup.js @@ -0,0 +1,68 @@ +// Jest setup for React Native testing +import '@testing-library/react-native/extend-expect'; + +// Mock expo modules +jest.mock('expo-linear-gradient', () => ({ + LinearGradient: 'LinearGradient', +})); + +jest.mock('expo-secure-store', () => ({ + setItemAsync: jest.fn(() => Promise.resolve()), + getItemAsync: jest.fn(() => Promise.resolve(null)), + deleteItemAsync: jest.fn(() => Promise.resolve()), +})); + +jest.mock('expo-local-authentication', () => ({ + authenticateAsync: jest.fn(() => + Promise.resolve({ success: true }) + ), + hasHardwareAsync: jest.fn(() => Promise.resolve(true)), + isEnrolledAsync: jest.fn(() => Promise.resolve(true)), +})); + +// Mock AsyncStorage +jest.mock('@react-native-async-storage/async-storage', () => + require('@react-native-async-storage/async-storage/jest/async-storage-mock') +); + +// Mock Polkadot.js +jest.mock('@polkadot/api', () => ({ + ApiPromise: { + create: jest.fn(() => + Promise.resolve({ + isReady: Promise.resolve(true), + query: {}, + tx: {}, + rpc: {}, + }) + ), + }, + WsProvider: jest.fn(), +})); + +// Mock Supabase +jest.mock('./src/lib/supabase', () => ({ + supabase: { + auth: { + signInWithPassword: jest.fn(), + signUp: jest.fn(), + signOut: jest.fn(), + getSession: jest.fn(), + }, + from: jest.fn(() => ({ + select: jest.fn().mockReturnThis(), + insert: jest.fn().mockReturnThis(), + update: jest.fn().mockReturnThis(), + delete: jest.fn().mockReturnThis(), + eq: jest.fn().mockReturnThis(), + order: jest.fn().mockReturnThis(), + })), + }, +})); + +// Silence console warnings in tests +global.console = { + ...console, + warn: jest.fn(), + error: jest.fn(), +}; diff --git a/mobile/metro.config.js b/mobile/metro.config.js new file mode 100644 index 00000000..18401def --- /dev/null +++ b/mobile/metro.config.js @@ -0,0 +1,71 @@ +// Learn more https://docs.expo.io/guides/customizing-metro +const { getDefaultConfig } = require('expo/metro-config'); +const path = require('path'); + +/** @type {import('expo/metro-config').MetroConfig} */ +const config = getDefaultConfig(__dirname); + +// Monorepo support: Watch and resolve modules from parent directory +const projectRoot = __dirname; +const workspaceRoot = path.resolve(projectRoot, '..'); + +// Watch all files in the monorepo +config.watchFolders = [workspaceRoot]; + +// Let Metro resolve modules from the workspace root +config.resolver.nodeModulesPaths = [ + path.resolve(projectRoot, 'node_modules'), + path.resolve(workspaceRoot, 'node_modules'), +]; + +// Enable symlinks for shared library +config.resolver.resolveRequest = (context, moduleName, platform) => { + // Handle @pezkuwi/* imports (shared library) + if (moduleName.startsWith('@pezkuwi/')) { + const sharedPath = moduleName.replace('@pezkuwi/', ''); + const sharedDir = path.resolve(workspaceRoot, 'shared', sharedPath); + + // Try .ts extension first, then .tsx, then .js + const extensions = ['.ts', '.tsx', '.js', '.json']; + for (const ext of extensions) { + const filePath = sharedDir + ext; + if (require('fs').existsSync(filePath)) { + return { + filePath, + type: 'sourceFile', + }; + } + } + + // Try index files + for (const ext of extensions) { + const indexPath = path.join(sharedDir, `index${ext}`); + if (require('fs').existsSync(indexPath)) { + return { + filePath: indexPath, + type: 'sourceFile', + }; + } + } + } + + // Fall back to the default resolver + return context.resolveRequest(context, moduleName, platform); +}; + +// Ensure all file extensions are resolved +config.resolver.sourceExts = [ + 'expo.ts', + 'expo.tsx', + 'expo.js', + 'expo.jsx', + 'ts', + 'tsx', + 'js', + 'jsx', + 'json', + 'wasm', + 'svg', +]; + +module.exports = config; diff --git a/mobile/package.json b/mobile/package.json index 8b507d30..00fff6d0 100644 --- a/mobile/package.json +++ b/mobile/package.json @@ -6,7 +6,10 @@ "start": "expo start", "android": "expo start --android", "ios": "expo start --ios", - "web": "expo start --web" + "web": "expo start --web", + "test": "jest", + "test:watch": "jest --watch", + "test:coverage": "jest --coverage" }, "dependencies": { "@polkadot/api": "^16.5.2", diff --git a/mobile/src/components/ErrorBoundary.tsx b/mobile/src/components/ErrorBoundary.tsx new file mode 100644 index 00000000..5ac6494d --- /dev/null +++ b/mobile/src/components/ErrorBoundary.tsx @@ -0,0 +1,250 @@ +// ======================================== +// Error Boundary Component (React Native) +// ======================================== +// Catches React errors and displays fallback UI + +import React, { Component, ErrorInfo, ReactNode } from 'react'; +import { View, Text, TouchableOpacity, ScrollView, StyleSheet } from 'react-native'; + +interface Props { + children: ReactNode; + fallback?: ReactNode; + onError?: (error: Error, errorInfo: ErrorInfo) => void; +} + +interface State { + hasError: boolean; + error: Error | null; + errorInfo: ErrorInfo | null; +} + +/** + * Global Error Boundary for React Native + * Catches unhandled errors in React component tree + * + * @example + * + * + * + */ +export class ErrorBoundary extends Component { + constructor(props: Props) { + super(props); + this.state = { + hasError: false, + error: null, + errorInfo: null, + }; + } + + static getDerivedStateFromError(error: Error): Partial { + // Update state so next render shows fallback UI + return { + hasError: true, + error, + }; + } + + componentDidCatch(error: Error, errorInfo: ErrorInfo): void { + // Log error to console + if (__DEV__) { + console.error('ErrorBoundary caught an error:', error, errorInfo); + } + + // Update state with error details + this.setState({ + error, + errorInfo, + }); + + // Call custom error handler if provided + if (this.props.onError) { + this.props.onError(error, errorInfo); + } + + // In production, you might want to log to an error reporting service + // Example: Sentry.captureException(error); + } + + handleReset = (): void => { + this.setState({ + hasError: false, + error: null, + errorInfo: null, + }); + }; + + render(): ReactNode { + if (this.state.hasError) { + // Use custom fallback if provided + if (this.props.fallback) { + return this.props.fallback; + } + + // Default error UI for React Native + return ( + + + + {/* Error Icon */} + + ⚠️ + + + {/* Error Title */} + Something Went Wrong + + An unexpected error occurred. We apologize for the inconvenience. + + + {/* Error Details (Development Only) */} + {__DEV__ && this.state.error && ( + + + Error Details (for developers) + + + Error: + + {this.state.error.toString()} + + {this.state.errorInfo && ( + <> + + Component Stack: + + + {this.state.errorInfo.componentStack} + + + )} + + + )} + + {/* Action Buttons */} + + + Try Again + + + + {/* Support Contact */} + + If this problem persists, please contact support at{' '} + info@pezkuwichain.io + + + + + ); + } + + // No error, render children normally + return this.props.children; + } +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: '#0a0a0a', + }, + scrollView: { + flex: 1, + }, + scrollContent: { + flexGrow: 1, + justifyContent: 'center', + padding: 16, + }, + card: { + backgroundColor: '#1a1a1a', + borderRadius: 12, + borderWidth: 1, + borderColor: '#2a2a2a', + padding: 24, + }, + iconContainer: { + alignItems: 'center', + marginBottom: 16, + }, + iconText: { + fontSize: 48, + }, + title: { + fontSize: 24, + fontWeight: 'bold', + color: '#ffffff', + textAlign: 'center', + marginBottom: 12, + }, + description: { + fontSize: 16, + color: '#9ca3af', + textAlign: 'center', + marginBottom: 24, + lineHeight: 24, + }, + errorDetails: { + marginBottom: 24, + }, + errorDetailsTitle: { + fontSize: 14, + fontWeight: '600', + color: '#6b7280', + marginBottom: 12, + }, + errorBox: { + backgroundColor: '#0a0a0a', + borderRadius: 8, + borderWidth: 1, + borderColor: '#374151', + padding: 12, + }, + errorLabel: { + fontSize: 12, + fontWeight: 'bold', + color: '#ef4444', + marginBottom: 8, + }, + stackLabel: { + marginTop: 12, + }, + errorText: { + fontSize: 11, + fontFamily: 'monospace', + color: '#9ca3af', + lineHeight: 16, + }, + buttonContainer: { + marginBottom: 16, + }, + primaryButton: { + backgroundColor: '#00A94F', + borderRadius: 8, + padding: 16, + alignItems: 'center', + }, + buttonText: { + color: '#ffffff', + fontSize: 16, + fontWeight: '600', + }, + supportText: { + fontSize: 14, + color: '#6b7280', + textAlign: 'center', + lineHeight: 20, + }, + supportEmail: { + color: '#00A94F', + }, +}); diff --git a/mobile/src/components/__tests__/ErrorBoundary.test.tsx b/mobile/src/components/__tests__/ErrorBoundary.test.tsx new file mode 100644 index 00000000..04c78fba --- /dev/null +++ b/mobile/src/components/__tests__/ErrorBoundary.test.tsx @@ -0,0 +1,81 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react-native'; +import { Text } from 'react-native'; +import { ErrorBoundary } from '../ErrorBoundary'; + +// Component that throws error for testing +const ThrowError = () => { + throw new Error('Test error'); + return null; +}; + +// Normal component for success case +const SuccessComponent = () => Success!; + +describe('ErrorBoundary', () => { + // Suppress error console logs during tests + const originalError = console.error; + beforeAll(() => { + console.error = jest.fn(); + }); + + afterAll(() => { + console.error = originalError; + }); + + it('should render children when no error occurs', () => { + render( + + + + ); + + expect(screen.getByText('Success!')).toBeTruthy(); + }); + + it('should render error UI when child throws error', () => { + render( + + + + ); + + expect(screen.getByText('Something Went Wrong')).toBeTruthy(); + expect(screen.getByText(/An unexpected error occurred/)).toBeTruthy(); + }); + + it('should display try again button on error', () => { + render( + + + + ); + + expect(screen.getByText('Try Again')).toBeTruthy(); + }); + + it('should render custom fallback if provided', () => { + const CustomFallback = () => Custom Error UI; + + render( + }> + + + ); + + expect(screen.getByText('Custom Error UI')).toBeTruthy(); + }); + + it('should call onError callback when error occurs', () => { + const onError = jest.fn(); + + render( + + + + ); + + expect(onError).toHaveBeenCalled(); + expect(onError.mock.calls[0][0].message).toBe('Test error'); + }); +}); diff --git a/mobile/src/components/index.ts b/mobile/src/components/index.ts index b1c311fd..d1fd2537 100644 --- a/mobile/src/components/index.ts +++ b/mobile/src/components/index.ts @@ -3,6 +3,7 @@ * Inspired by Material Design 3, iOS HIG, and Kurdistan aesthetics */ +export { ErrorBoundary } from './ErrorBoundary'; export { Card } from './Card'; export { Button } from './Button'; export { Input } from './Input'; diff --git a/mobile/src/contexts/AuthContext.tsx b/mobile/src/contexts/AuthContext.tsx new file mode 100644 index 00000000..0a1a47a9 --- /dev/null +++ b/mobile/src/contexts/AuthContext.tsx @@ -0,0 +1,243 @@ +import React, { createContext, useContext, useEffect, useState, useCallback } from 'react'; +import { supabase } from '../lib/supabase'; +import AsyncStorage from '@react-native-async-storage/async-storage'; +import type { User } from '@supabase/supabase-js'; + +// Session timeout configuration +const SESSION_TIMEOUT_MS = 30 * 60 * 1000; // 30 minutes +const ACTIVITY_CHECK_INTERVAL_MS = 60 * 1000; // Check every 1 minute +const LAST_ACTIVITY_KEY = '@pezkuwi_last_activity'; + +interface AuthContextType { + user: User | null; + loading: boolean; + isAdmin: boolean; + signIn: (email: string, password: string) => Promise<{ error: Error | null }>; + signUp: (email: string, password: string, username: string, referralCode?: string) => Promise<{ error: Error | null }>; + signOut: () => Promise; + checkAdminStatus: () => Promise; + updateActivity: () => void; +} + +const AuthContext = createContext(undefined); + +export const useAuth = () => { + const context = useContext(AuthContext); + if (!context) { + throw new Error('useAuth must be used within an AuthProvider'); + } + return context; +}; + +export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { + const [user, setUser] = useState(null); + const [loading, setLoading] = useState(true); + const [isAdmin, setIsAdmin] = useState(false); + + // Update last activity timestamp + const updateActivity = useCallback(async () => { + try { + await AsyncStorage.setItem(LAST_ACTIVITY_KEY, Date.now().toString()); + } catch (error) { + if (__DEV__) console.error('Failed to update activity:', error); + } + }, []); + + const signOut = useCallback(async () => { + setIsAdmin(false); + setUser(null); + await AsyncStorage.removeItem(LAST_ACTIVITY_KEY); + await supabase.auth.signOut(); + }, []); + + // Check if session has timed out + const checkSessionTimeout = useCallback(async () => { + if (!user) return; + + try { + const lastActivity = await AsyncStorage.getItem(LAST_ACTIVITY_KEY); + if (!lastActivity) { + await updateActivity(); + return; + } + + const lastActivityTime = parseInt(lastActivity, 10); + const now = Date.now(); + const inactiveTime = now - lastActivityTime; + + if (inactiveTime >= SESSION_TIMEOUT_MS) { + if (__DEV__) console.log('⏱️ Session timeout - logging out due to inactivity'); + await signOut(); + } + } catch (error) { + if (__DEV__) console.error('Error checking session timeout:', error); + } + }, [user, updateActivity, signOut]); + + // Setup activity monitoring + useEffect(() => { + if (!user) return; + + // Initial activity timestamp + updateActivity(); + + // Check for timeout periodically + const timeoutChecker = setInterval(checkSessionTimeout, ACTIVITY_CHECK_INTERVAL_MS); + + return () => { + clearInterval(timeoutChecker); + }; + }, [user, updateActivity, checkSessionTimeout]); + + // Check admin status + const checkAdminStatus = useCallback(async (): Promise => { + if (!user) return false; + + try { + const { data, error } = await supabase + .from('profiles') + .select('is_admin') + .eq('id', user.id) + .single(); + + if (error) { + if (__DEV__) console.error('Error checking admin status:', error); + return false; + } + + const adminStatus = data?.is_admin || false; + setIsAdmin(adminStatus); + return adminStatus; + } catch (error) { + if (__DEV__) console.error('Error in checkAdminStatus:', error); + return false; + } + }, [user]); + + // Sign in function + const signIn = async (email: string, password: string): Promise<{ error: Error | null }> => { + try { + const { data, error } = await supabase.auth.signInWithPassword({ + email, + password, + }); + + if (error) { + return { error }; + } + + if (data.user) { + setUser(data.user); + await updateActivity(); + await checkAdminStatus(); + } + + return { error: null }; + } catch (error) { + return { error: error as Error }; + } + }; + + // Sign up function + const signUp = async ( + email: string, + password: string, + username: string, + referralCode?: string + ): Promise<{ error: Error | null }> => { + try { + // Create auth user + const { data: authData, error: authError } = await supabase.auth.signUp({ + email, + password, + options: { + data: { + username, + referral_code: referralCode, + }, + }, + }); + + if (authError) { + return { error: authError }; + } + + // Create profile + if (authData.user) { + const { error: profileError } = await supabase + .from('profiles') + .insert({ + id: authData.user.id, + username, + email, + referral_code: referralCode, + }); + + if (profileError) { + if (__DEV__) console.error('Profile creation error:', profileError); + // Don't fail signup if profile creation fails + } + + setUser(authData.user); + await updateActivity(); + } + + return { error: null }; + } catch (error) { + return { error: error as Error }; + } + }; + + // Initialize auth state + useEffect(() => { + const initAuth = async () => { + try { + // Get initial session + const { data: { session } } = await supabase.auth.getSession(); + + if (session?.user) { + setUser(session.user); + await checkAdminStatus(); + await updateActivity(); + } + } catch (error) { + if (__DEV__) console.error('Error initializing auth:', error); + } finally { + setLoading(false); + } + }; + + initAuth(); + + // Listen for auth changes + const { data: { subscription } } = supabase.auth.onAuthStateChange( + async (_event, session) => { + setUser(session?.user ?? null); + + if (session?.user) { + await checkAdminStatus(); + await updateActivity(); + } else { + setIsAdmin(false); + } + } + ); + + return () => { + subscription.unsubscribe(); + }; + }, [checkAdminStatus, updateActivity]); + + const value = { + user, + loading, + isAdmin, + signIn, + signUp, + signOut, + checkAdminStatus, + updateActivity, + }; + + return {children}; +}; diff --git a/mobile/src/contexts/BiometricAuthContext.tsx b/mobile/src/contexts/BiometricAuthContext.tsx index f008ff4e..d5a28420 100644 --- a/mobile/src/contexts/BiometricAuthContext.tsx +++ b/mobile/src/contexts/BiometricAuthContext.tsx @@ -85,7 +85,7 @@ export const BiometricAuthProvider: React.FC<{ children: React.ReactNode }> = ({ } } } catch (error) { - console.error('Biometric init error:', error); + if (__DEV__) console.error('Biometric init error:', error); } }; @@ -108,7 +108,7 @@ export const BiometricAuthProvider: React.FC<{ children: React.ReactNode }> = ({ // Check if app should be locked await checkAutoLock(); } catch (error) { - console.error('Error loading settings:', error); + if (__DEV__) console.error('Error loading settings:', error); } }; @@ -136,7 +136,7 @@ export const BiometricAuthProvider: React.FC<{ children: React.ReactNode }> = ({ return false; } catch (error) { - console.error('Authentication error:', error); + if (__DEV__) console.error('Authentication error:', error); return false; } }; @@ -159,7 +159,7 @@ export const BiometricAuthProvider: React.FC<{ children: React.ReactNode }> = ({ return false; } catch (error) { - console.error('Enable biometric error:', error); + if (__DEV__) console.error('Enable biometric error:', error); return false; } }; @@ -173,7 +173,7 @@ export const BiometricAuthProvider: React.FC<{ children: React.ReactNode }> = ({ await AsyncStorage.setItem(BIOMETRIC_ENABLED_KEY, 'false'); setIsBiometricEnabled(false); } catch (error) { - console.error('Disable biometric error:', error); + if (__DEV__) console.error('Disable biometric error:', error); } }; @@ -191,7 +191,7 @@ export const BiometricAuthProvider: React.FC<{ children: React.ReactNode }> = ({ // Store in SecureStore (encrypted on device) await SecureStore.setItemAsync(PIN_CODE_KEY, hashedPin); } catch (error) { - console.error('Set PIN error:', error); + if (__DEV__) console.error('Set PIN error:', error); throw error; } }; @@ -220,7 +220,7 @@ export const BiometricAuthProvider: React.FC<{ children: React.ReactNode }> = ({ return false; } catch (error) { - console.error('Verify PIN error:', error); + if (__DEV__) console.error('Verify PIN error:', error); return false; } }; @@ -250,7 +250,7 @@ export const BiometricAuthProvider: React.FC<{ children: React.ReactNode }> = ({ await AsyncStorage.setItem(AUTO_LOCK_TIMER_KEY, minutes.toString()); setAutoLockTimerState(minutes); } catch (error) { - console.error('Set auto-lock timer error:', error); + if (__DEV__) console.error('Set auto-lock timer error:', error); } }; @@ -273,7 +273,7 @@ export const BiometricAuthProvider: React.FC<{ children: React.ReactNode }> = ({ try { await AsyncStorage.setItem(LAST_UNLOCK_TIME_KEY, Date.now().toString()); } catch (error) { - console.error('Save unlock time error:', error); + if (__DEV__) console.error('Save unlock time error:', error); } }; @@ -303,7 +303,7 @@ export const BiometricAuthProvider: React.FC<{ children: React.ReactNode }> = ({ setIsLocked(false); } } catch (error) { - console.error('Check auto-lock error:', error); + if (__DEV__) console.error('Check auto-lock error:', error); // On error, lock for safety setIsLocked(true); } diff --git a/mobile/src/contexts/LanguageContext.tsx b/mobile/src/contexts/LanguageContext.tsx index e8436027..10aa5baa 100644 --- a/mobile/src/contexts/LanguageContext.tsx +++ b/mobile/src/contexts/LanguageContext.tsx @@ -29,7 +29,7 @@ export const LanguageProvider: React.FC<{ children: ReactNode }> = ({ children } const saved = await AsyncStorage.getItem(LANGUAGE_KEY); setHasSelectedLanguage(!!saved); } catch (error) { - console.error('Failed to check language selection:', error); + if (__DEV__) console.error('Failed to check language selection:', error); } }; @@ -49,7 +49,7 @@ export const LanguageProvider: React.FC<{ children: ReactNode }> = ({ children } // You may want to show a message to restart the app } } catch (error) { - console.error('Failed to change language:', error); + if (__DEV__) console.error('Failed to change language:', error); } }; diff --git a/mobile/src/contexts/PolkadotContext.tsx b/mobile/src/contexts/PolkadotContext.tsx index d821f80d..e4a5cd61 100644 --- a/mobile/src/contexts/PolkadotContext.tsx +++ b/mobile/src/contexts/PolkadotContext.tsx @@ -3,6 +3,7 @@ import { ApiPromise, WsProvider } from '@polkadot/api'; import { Keyring } from '@polkadot/keyring'; import { KeyringPair } from '@polkadot/keyring/types'; import AsyncStorage from '@react-native-async-storage/async-storage'; +import * as SecureStore from 'expo-secure-store'; import { cryptoWaitReady } from '@polkadot/util-crypto'; import { DEFAULT_ENDPOINT } from '../../../shared/blockchain/polkadot'; @@ -56,9 +57,9 @@ export const PolkadotProvider: React.FC = ({ await cryptoWaitReady(); const kr = new Keyring({ type: 'sr25519' }); setKeyring(kr); - console.log('✅ Crypto libraries initialized'); + if (__DEV__) console.log('✅ Crypto libraries initialized'); } catch (err) { - console.error('❌ Failed to initialize crypto:', err); + if (__DEV__) console.error('❌ Failed to initialize crypto:', err); setError('Failed to initialize crypto libraries'); } }; @@ -70,7 +71,7 @@ export const PolkadotProvider: React.FC = ({ useEffect(() => { const initApi = async () => { try { - console.log('🔗 Connecting to Pezkuwi node:', endpoint); + if (__DEV__) console.log('🔗 Connecting to Pezkuwi node:', endpoint); const provider = new WsProvider(endpoint); const apiInstance = await ApiPromise.create({ provider }); @@ -81,7 +82,7 @@ export const PolkadotProvider: React.FC = ({ setIsApiReady(true); setError(null); - console.log('✅ Connected to Pezkuwi node'); + if (__DEV__) console.log('✅ Connected to Pezkuwi node'); // Get chain info const [chain, nodeName, nodeVersion] = await Promise.all([ @@ -90,10 +91,12 @@ export const PolkadotProvider: React.FC = ({ apiInstance.rpc.system.version(), ]); - console.log(`📡 Chain: ${chain}`); - console.log(`🖥️ Node: ${nodeName} v${nodeVersion}`); + if (__DEV__) { + console.log(`📡 Chain: ${chain}`); + console.log(`🖥️ Node: ${nodeName} v${nodeVersion}`); + } } catch (err) { - console.error('❌ Failed to connect to node:', err); + if (__DEV__) console.error('❌ Failed to connect to node:', err); setError(`Failed to connect to node: ${endpoint}`); setIsApiReady(false); } @@ -127,7 +130,7 @@ export const PolkadotProvider: React.FC = ({ } } } catch (err) { - console.error('Failed to load accounts:', err); + if (__DEV__) console.error('Failed to load accounts:', err); } }; @@ -161,18 +164,18 @@ export const PolkadotProvider: React.FC = ({ setAccounts(updatedAccounts); await AsyncStorage.setItem(WALLET_STORAGE_KEY, JSON.stringify(updatedAccounts)); - // Store encrypted seed separately - const seedKey = `@pezkuwi_seed_${pair.address}`; - await AsyncStorage.setItem(seedKey, mnemonicPhrase); + // SECURITY: Store encrypted seed in SecureStore (encrypted hardware-backed storage) + const seedKey = `pezkuwi_seed_${pair.address}`; + await SecureStore.setItemAsync(seedKey, mnemonicPhrase); - console.log('✅ Wallet created:', pair.address); + if (__DEV__) console.log('✅ Wallet created:', pair.address); return { address: pair.address, mnemonic: mnemonicPhrase, }; } catch (err) { - console.error('❌ Failed to create wallet:', err); + if (__DEV__) console.error('❌ Failed to create wallet:', err); throw new Error('Failed to create wallet'); } }; @@ -184,12 +187,12 @@ export const PolkadotProvider: React.FC = ({ } try { - // Load seed from storage - const seedKey = `@pezkuwi_seed_${address}`; - const mnemonic = await AsyncStorage.getItem(seedKey); + // SECURITY: Load seed from SecureStore (encrypted storage) + const seedKey = `pezkuwi_seed_${address}`; + const mnemonic = await SecureStore.getItemAsync(seedKey); if (!mnemonic) { - console.error('No seed found for address:', address); + if (__DEV__) console.error('No seed found for address:', address); return null; } @@ -197,7 +200,7 @@ export const PolkadotProvider: React.FC = ({ const pair = keyring.addFromMnemonic(mnemonic); return pair; } catch (err) { - console.error('Failed to get keypair:', err); + if (__DEV__) console.error('Failed to get keypair:', err); return null; } }; @@ -218,9 +221,9 @@ export const PolkadotProvider: React.FC = ({ await AsyncStorage.setItem(SELECTED_ACCOUNT_KEY, accounts[0].address); } - console.log(`✅ Connected with ${accounts.length} account(s)`); + if (__DEV__) console.log(`✅ Connected with ${accounts.length} account(s)`); } catch (err) { - console.error('❌ Wallet connection failed:', err); + if (__DEV__) console.error('❌ Wallet connection failed:', err); setError('Failed to connect wallet'); } }; @@ -229,7 +232,7 @@ export const PolkadotProvider: React.FC = ({ const disconnectWallet = () => { setSelectedAccount(null); AsyncStorage.removeItem(SELECTED_ACCOUNT_KEY); - console.log('🔌 Wallet disconnected'); + if (__DEV__) console.log('🔌 Wallet disconnected'); }; // Update selected account storage when it changes diff --git a/mobile/src/contexts/__tests__/AuthContext.test.tsx b/mobile/src/contexts/__tests__/AuthContext.test.tsx new file mode 100644 index 00000000..4c795eaf --- /dev/null +++ b/mobile/src/contexts/__tests__/AuthContext.test.tsx @@ -0,0 +1,100 @@ +import React from 'react'; +import { renderHook, act, waitFor } from '@testing-library/react-native'; +import { AuthProvider, useAuth } from '../AuthContext'; +import { supabase } from '../../lib/supabase'; + +// Wrapper for provider +const wrapper = ({ children }: { children: React.ReactNode }) => ( + {children} +); + +describe('AuthContext', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should provide auth context', () => { + const { result } = renderHook(() => useAuth(), { wrapper }); + + expect(result.current).toBeDefined(); + expect(result.current.user).toBeNull(); + expect(result.current.loading).toBe(true); + }); + + it('should sign in with email and password', async () => { + const mockUser = { id: '123', email: 'test@example.com' }; + (supabase.auth.signInWithPassword as jest.Mock).mockResolvedValue({ + data: { user: mockUser }, + error: null, + }); + + const { result } = renderHook(() => useAuth(), { wrapper }); + + await act(async () => { + const response = await result.current.signIn('test@example.com', 'password123'); + expect(response.error).toBeNull(); + }); + + expect(supabase.auth.signInWithPassword).toHaveBeenCalledWith({ + email: 'test@example.com', + password: 'password123', + }); + }); + + it('should handle sign in error', async () => { + const mockError = new Error('Invalid credentials'); + (supabase.auth.signInWithPassword as jest.Mock).mockResolvedValue({ + data: null, + error: mockError, + }); + + const { result } = renderHook(() => useAuth(), { wrapper }); + + await act(async () => { + const response = await result.current.signIn('test@example.com', 'wrong-password'); + expect(response.error).toBeDefined(); + }); + }); + + it('should sign up new user', async () => { + const mockUser = { id: '456', email: 'new@example.com' }; + (supabase.auth.signUp as jest.Mock).mockResolvedValue({ + data: { user: mockUser }, + error: null, + }); + + const { result } = renderHook(() => useAuth(), { wrapper }); + + await act(async () => { + const response = await result.current.signUp( + 'new@example.com', + 'password123', + 'newuser' + ); + expect(response.error).toBeNull(); + }); + + expect(supabase.auth.signUp).toHaveBeenCalled(); + }); + + it('should sign out user', async () => { + (supabase.auth.signOut as jest.Mock).mockResolvedValue({ error: null }); + + const { result } = renderHook(() => useAuth(), { wrapper }); + + await act(async () => { + await result.current.signOut(); + }); + + expect(supabase.auth.signOut).toHaveBeenCalled(); + }); + + it('should check admin status', async () => { + const { result } = renderHook(() => useAuth(), { wrapper }); + + await act(async () => { + const isAdmin = await result.current.checkAdminStatus(); + expect(typeof isAdmin).toBe('boolean'); + }); + }); +}); diff --git a/mobile/src/i18n/index.ts b/mobile/src/i18n/index.ts index e27af39a..0b7f29ed 100644 --- a/mobile/src/i18n/index.ts +++ b/mobile/src/i18n/index.ts @@ -27,7 +27,7 @@ const initializeI18n = async () => { savedLanguage = stored; } } catch (error) { - console.warn('Failed to load saved language:', error); + if (__DEV__) console.warn('Failed to load saved language:', error); } i18n @@ -58,7 +58,7 @@ export const saveLanguage = async (languageCode: string) => { await AsyncStorage.setItem(LANGUAGE_KEY, languageCode); await i18n.changeLanguage(languageCode); } catch (error) { - console.error('Failed to save language:', error); + if (__DEV__) console.error('Failed to save language:', error); } }; diff --git a/mobile/src/lib/supabase.ts b/mobile/src/lib/supabase.ts new file mode 100644 index 00000000..d6dd4433 --- /dev/null +++ b/mobile/src/lib/supabase.ts @@ -0,0 +1,23 @@ +import 'react-native-url-polyfill/auto'; +import { createClient } from '@supabase/supabase-js'; +import AsyncStorage from '@react-native-async-storage/async-storage'; + +// Initialize Supabase client from environment variables +const supabaseUrl = process.env.EXPO_PUBLIC_SUPABASE_URL || ''; +const supabaseKey = process.env.EXPO_PUBLIC_SUPABASE_ANON_KEY || ''; + +if (!supabaseUrl || !supabaseKey) { + if (__DEV__) { + console.warn('Supabase credentials not found in environment variables'); + console.warn('Add EXPO_PUBLIC_SUPABASE_URL and EXPO_PUBLIC_SUPABASE_ANON_KEY to .env'); + } +} + +export const supabase = createClient(supabaseUrl, supabaseKey, { + auth: { + storage: AsyncStorage, + autoRefreshToken: true, + persistSession: true, + detectSessionInUrl: false, + }, +}); diff --git a/mobile/src/screens/BeCitizenScreen.tsx b/mobile/src/screens/BeCitizenScreen.tsx index b90d0387..b1f63df1 100644 --- a/mobile/src/screens/BeCitizenScreen.tsx +++ b/mobile/src/screens/BeCitizenScreen.tsx @@ -9,15 +9,20 @@ import { StatusBar, TextInput, Alert, + ActivityIndicator, } from 'react-native'; import { LinearGradient } from 'expo-linear-gradient'; import { useTranslation } from 'react-i18next'; +import { usePolkadot } from '../contexts/PolkadotContext'; +import { submitKycApplication, uploadToIPFS } from '@pezkuwi/lib/citizenship-workflow'; import AppColors, { KurdistanColors } from '../theme/colors'; const BeCitizenScreen: React.FC = () => { const { t } = useTranslation(); + const { api, selectedAccount } = usePolkadot(); const [isExistingCitizen, setIsExistingCitizen] = useState(false); const [currentStep, setCurrentStep] = useState<'choice' | 'new' | 'existing'>('choice'); + const [isSubmitting, setIsSubmitting] = useState(false); // New Citizen Form State const [fullName, setFullName] = useState(''); @@ -33,34 +38,82 @@ const BeCitizenScreen: React.FC = () => { const [citizenId, setCitizenId] = useState(''); const [password, setPassword] = useState(''); - const handleNewCitizenApplication = () => { + const handleNewCitizenApplication = async () => { if (!fullName || !fatherName || !motherName || !email) { Alert.alert('Error', 'Please fill in all required fields'); return; } - // TODO: Implement actual citizenship registration on blockchain - Alert.alert( - 'Application Submitted', - 'Your citizenship application has been submitted for review. You will receive a confirmation soon.', - [ - { - text: 'OK', - onPress: () => { - // Reset form - setFullName(''); - setFatherName(''); - setMotherName(''); - setTribe(''); - setRegion(''); - setEmail(''); - setProfession(''); - setReferralCode(''); - setCurrentStep('choice'); - }, - }, - ] - ); + if (!api || !selectedAccount) { + Alert.alert('Error', 'Please connect your wallet first'); + return; + } + + setIsSubmitting(true); + + try { + // Prepare citizenship data + const citizenshipData = { + fullName, + fatherName, + motherName, + tribe, + region, + email, + profession, + referralCode, + walletAddress: selectedAccount.address, + timestamp: Date.now(), + }; + + // Step 1: Upload encrypted data to IPFS + const ipfsCid = await uploadToIPFS(citizenshipData); + + if (!ipfsCid) { + throw new Error('Failed to upload data to IPFS'); + } + + // Step 2: Submit KYC application to blockchain + const result = await submitKycApplication( + api, + selectedAccount, + fullName, + email, + ipfsCid, + 'Citizenship application via mobile app' + ); + + if (result.success) { + Alert.alert( + 'Application Submitted!', + 'Your citizenship application has been submitted for review. You will receive a confirmation once approved.', + [ + { + text: 'OK', + onPress: () => { + // Reset form + setFullName(''); + setFatherName(''); + setMotherName(''); + setTribe(''); + setRegion(''); + setEmail(''); + setProfession(''); + setReferralCode(''); + setCurrentStep('choice'); + }, + }, + ] + ); + } else { + Alert.alert('Application Failed', result.error || 'Failed to submit application'); + } + } catch (error: any) { + if (__DEV__) console.error('Citizenship application error:', error); + Alert.alert('Error', error.message || 'An unexpected error occurred'); + } finally { + setIsSubmitting(false); + } }; const handleExistingCitizenLogin = () => { @@ -265,11 +318,16 @@ const BeCitizenScreen: React.FC = () => { - Submit Application + {isSubmitting ? ( + + ) : ( + Submit Application + )} @@ -485,6 +543,9 @@ const styles = StyleSheet.create({ shadowRadius: 6, elevation: 6, }, + submitButtonDisabled: { + opacity: 0.6, + }, submitButtonText: { fontSize: 18, fontWeight: 'bold', diff --git a/mobile/src/screens/EducationScreen.tsx b/mobile/src/screens/EducationScreen.tsx index 8572dd57..ce58ecbb 100644 --- a/mobile/src/screens/EducationScreen.tsx +++ b/mobile/src/screens/EducationScreen.tsx @@ -47,7 +47,7 @@ const EducationScreen: React.FC = () => { const allCourses = await getAllCourses(api); setCourses(allCourses); } catch (error) { - console.error('Failed to fetch courses:', error); + if (__DEV__) console.error('Failed to fetch courses:', error); } finally { setLoading(false); setRefreshing(false); @@ -64,7 +64,7 @@ const EducationScreen: React.FC = () => { const studentEnrollments = await getStudentEnrollments(selectedAccount.address); setEnrollments(studentEnrollments); } catch (error) { - console.error('Failed to fetch enrollments:', error); + if (__DEV__) console.error('Failed to fetch enrollments:', error); } }, [selectedAccount]); @@ -102,7 +102,7 @@ const EducationScreen: React.FC = () => { Alert.alert('Success', 'Successfully enrolled in course!'); fetchEnrollments(); } catch (error: any) { - console.error('Enrollment failed:', error); + if (__DEV__) console.error('Enrollment failed:', error); Alert.alert('Enrollment Failed', error.message || 'Failed to enroll in course'); } finally { setEnrolling(null); @@ -138,7 +138,7 @@ const EducationScreen: React.FC = () => { Alert.alert('Success', 'Course completed! Certificate issued.'); fetchEnrollments(); } catch (error: any) { - console.error('Completion failed:', error); + if (__DEV__) console.error('Completion failed:', error); Alert.alert('Error', error.message || 'Failed to complete course'); } }, diff --git a/mobile/src/screens/ForumScreen.tsx b/mobile/src/screens/ForumScreen.tsx index 23c293ae..c5af1d08 100644 --- a/mobile/src/screens/ForumScreen.tsx +++ b/mobile/src/screens/ForumScreen.tsx @@ -12,6 +12,7 @@ import { import { useTranslation } from 'react-i18next'; import { Card, Badge } from '../components'; import { KurdistanColors, AppColors } from '../theme/colors'; +import { supabase } from '../lib/supabase'; interface ForumThread { id: string; @@ -123,18 +124,54 @@ const ForumScreen: React.FC = () => { const fetchThreads = async (categoryId?: string) => { setLoading(true); try { - // TODO: Fetch from Supabase - // const { data } = await supabase - // .from('forum_threads') - // .select('*') - // .eq('category_id', categoryId) - // .order('is_pinned', { ascending: false }) - // .order('last_activity', { ascending: false }); + // Fetch from Supabase + let query = supabase + .from('forum_threads') + .select(` + *, + forum_categories(name) + `) + .order('is_pinned', { ascending: false }) + .order('last_activity', { ascending: false }); - await new Promise((resolve) => setTimeout(resolve, 500)); - setThreads(MOCK_THREADS); + // Filter by category if provided + if (categoryId) { + query = query.eq('category_id', categoryId); + } + + const { data, error } = await query; + + if (error) { + if (__DEV__) console.error('Supabase fetch error:', error); + // Fallback to mock data on error + setThreads(MOCK_THREADS); + return; + } + + if (data && data.length > 0) { + // Transform Supabase data to match ForumThread interface + const transformedThreads: ForumThread[] = data.map((thread: any) => ({ + id: thread.id, + title: thread.title, + content: thread.content, + author: thread.author_id, + category: thread.forum_categories?.name || 'Unknown', + replies_count: thread.replies_count || 0, + views_count: thread.views_count || 0, + created_at: thread.created_at, + last_activity: thread.last_activity || thread.created_at, + is_pinned: thread.is_pinned || false, + is_locked: thread.is_locked || false, + })); + setThreads(transformedThreads); + } else { + // No data, use mock data + setThreads(MOCK_THREADS); + } } catch (error) { - console.error('Failed to fetch threads:', error); + if (__DEV__) console.error('Failed to fetch threads:', error); + // Fallback to mock data on error + setThreads(MOCK_THREADS); } finally { setLoading(false); setRefreshing(false); diff --git a/mobile/src/screens/GovernanceScreen.tsx b/mobile/src/screens/GovernanceScreen.tsx index 52ad6baf..a32aaebe 100644 --- a/mobile/src/screens/GovernanceScreen.tsx +++ b/mobile/src/screens/GovernanceScreen.tsx @@ -121,7 +121,7 @@ export default function GovernanceScreen() { setProposals(proposalsList); } catch (error) { - console.error('Error fetching proposals:', error); + if (__DEV__) console.error('Error fetching proposals:', error); Alert.alert('Error', 'Failed to load proposals'); } finally { setLoading(false); @@ -162,7 +162,7 @@ export default function GovernanceScreen() { ]; setElections(mockElections); } catch (error) { - console.error('Error fetching elections:', error); + if (__DEV__) console.error('Error fetching elections:', error); } }; @@ -191,7 +191,7 @@ export default function GovernanceScreen() { } }); } catch (error: any) { - console.error('Voting error:', error); + if (__DEV__) console.error('Voting error:', error); Alert.alert('Error', error.message || 'Failed to submit vote'); } finally { setVoting(false); @@ -220,18 +220,72 @@ export default function GovernanceScreen() { return; } + if (!api || !selectedAccount || !selectedElection) { + Alert.alert('Error', 'Wallet not connected'); + return; + } + try { setVoting(true); - // TODO: Submit votes to blockchain via pallet-tiki - // await api.tx.tiki.voteInElection(electionId, candidateIds).signAndSend(...) - Alert.alert('Success', 'Your vote has been recorded!'); - setElectionSheetVisible(false); - setSelectedElection(null); - setVotedCandidates([]); - fetchElections(); + // Submit vote to blockchain via pallet-welati + // For single vote (Presidential): api.tx.welati.voteInElection(electionId, candidateId) + // For multiple votes (Parliamentary): submit each vote separately + const electionId = selectedElection.id; + + if (selectedElection.type === 'Parliamentary') { + // Submit multiple votes for parliamentary elections + const txs = votedCandidates.map(candidateId => + api.tx.welati.voteInElection(electionId, candidateId) + ); + + // Batch all votes together + const batchTx = api.tx.utility.batch(txs); + + await batchTx.signAndSend(selectedAccount.address, ({ status, dispatchError }) => { + if (dispatchError) { + if (dispatchError.isModule) { + const decoded = api.registry.findMetaError(dispatchError.asModule); + throw new Error(`${decoded.section}.${decoded.name}: ${decoded.docs}`); + } else { + throw new Error(dispatchError.toString()); + } + } + + if (status.isInBlock) { + Alert.alert('Success', `Your ${votedCandidates.length} votes have been recorded!`); + setElectionSheetVisible(false); + setSelectedElection(null); + setVotedCandidates([]); + fetchElections(); + } + }); + } else { + // Single vote for presidential/other elections + const candidateId = votedCandidates[0]; + const tx = api.tx.welati.voteInElection(electionId, candidateId); + + await tx.signAndSend(selectedAccount.address, ({ status, dispatchError }) => { + if (dispatchError) { + if (dispatchError.isModule) { + const decoded = api.registry.findMetaError(dispatchError.asModule); + throw new Error(`${decoded.section}.${decoded.name}: ${decoded.docs}`); + } else { + throw new Error(dispatchError.toString()); + } + } + + if (status.isInBlock) { + Alert.alert('Success', 'Your vote has been recorded!'); + setElectionSheetVisible(false); + setSelectedElection(null); + setVotedCandidates([]); + fetchElections(); + } + }); + } } catch (error: any) { - console.error('Election voting error:', error); + if (__DEV__) console.error('Election voting error:', error); Alert.alert('Error', error.message || 'Failed to submit vote'); } finally { setVoting(false); diff --git a/mobile/src/screens/NFTGalleryScreen.tsx b/mobile/src/screens/NFTGalleryScreen.tsx index a85674a1..44c5c70b 100644 --- a/mobile/src/screens/NFTGalleryScreen.tsx +++ b/mobile/src/screens/NFTGalleryScreen.tsx @@ -110,7 +110,7 @@ export default function NFTGalleryScreen() { setNfts(nftList); } catch (error) { - console.error('Error fetching NFTs:', error); + if (__DEV__) console.error('Error fetching NFTs:', error); } finally { setLoading(false); setRefreshing(false); diff --git a/mobile/src/screens/P2PScreen.tsx b/mobile/src/screens/P2PScreen.tsx index 93b57fe9..71d00f15 100644 --- a/mobile/src/screens/P2PScreen.tsx +++ b/mobile/src/screens/P2PScreen.tsx @@ -9,6 +9,9 @@ import { FlatList, ActivityIndicator, RefreshControl, + Modal, + TextInput, + Alert, } from 'react-native'; import { useTranslation } from 'react-i18next'; import { Card, Button, Badge } from '../components'; @@ -39,6 +42,9 @@ const P2PScreen: React.FC = () => { const [loading, setLoading] = useState(true); const [refreshing, setRefreshing] = useState(false); const [showCreateOffer, setShowCreateOffer] = useState(false); + const [showTradeModal, setShowTradeModal] = useState(false); + const [selectedOffer, setSelectedOffer] = useState(null); + const [tradeAmount, setTradeAmount] = useState(''); useEffect(() => { fetchOffers(); @@ -64,7 +70,7 @@ const P2PScreen: React.FC = () => { setOffers(enrichedOffers); } catch (error) { - console.error('Fetch offers error:', error); + if (__DEV__) console.error('Fetch offers error:', error); } finally { setLoading(false); setRefreshing(false); @@ -190,8 +196,8 @@ const P2PScreen: React.FC = () => { + + )} + + + + + + {/* Create Offer Modal */} + setShowCreateOffer(false)} + > + + + + Create Offer + setShowCreateOffer(false)}> + + + + + + + 🚧 + Coming Soon + + Create P2P offer functionality will be available in the next update. + The blockchain integration is ready and waiting for final testing! + + + + + + + ); }; @@ -483,6 +642,136 @@ const styles = StyleSheet.create({ textAlign: 'center', marginBottom: 24, }, + modalOverlay: { + flex: 1, + backgroundColor: 'rgba(0, 0, 0, 0.5)', + justifyContent: 'flex-end', + }, + modalContent: { + backgroundColor: '#FFFFFF', + borderTopLeftRadius: 20, + borderTopRightRadius: 20, + paddingTop: 20, + paddingHorizontal: 20, + paddingBottom: 40, + maxHeight: '90%', + }, + modalHeader: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + marginBottom: 20, + paddingBottom: 16, + borderBottomWidth: 1, + borderBottomColor: '#E0E0E0', + }, + modalTitle: { + fontSize: 20, + fontWeight: '700', + color: '#000', + }, + modalClose: { + fontSize: 24, + color: '#666', + fontWeight: '600', + }, + modalSection: { + marginBottom: 20, + }, + modalSectionTitle: { + fontSize: 12, + color: '#666', + marginBottom: 8, + textTransform: 'uppercase', + }, + modalAddress: { + fontSize: 16, + fontWeight: '600', + color: '#000', + }, + priceSection: { + backgroundColor: '#F5F5F5', + padding: 16, + borderRadius: 12, + }, + priceRow: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + marginBottom: 8, + }, + priceLabel: { + fontSize: 14, + color: '#666', + }, + priceValue: { + fontSize: 16, + fontWeight: '700', + color: KurdistanColors.kesk, + }, + inputLabel: { + fontSize: 14, + fontWeight: '600', + color: '#000', + marginBottom: 8, + }, + modalInput: { + backgroundColor: '#F5F5F5', + borderRadius: 12, + padding: 16, + fontSize: 16, + borderWidth: 1, + borderColor: '#E0E0E0', + }, + inputHint: { + fontSize: 12, + color: '#666', + marginTop: 4, + }, + calculationSection: { + backgroundColor: 'rgba(0, 169, 79, 0.1)', + padding: 16, + borderRadius: 12, + borderWidth: 1, + borderColor: 'rgba(0, 169, 79, 0.3)', + }, + calculationLabel: { + fontSize: 12, + color: '#666', + marginBottom: 4, + }, + calculationValue: { + fontSize: 24, + fontWeight: '700', + color: KurdistanColors.kesk, + }, + tradeModalButton: { + marginTop: 20, + }, + comingSoonContainer: { + alignItems: 'center', + paddingVertical: 40, + }, + comingSoonIcon: { + fontSize: 64, + marginBottom: 16, + }, + comingSoonTitle: { + fontSize: 20, + fontWeight: '700', + color: '#000', + marginBottom: 12, + }, + comingSoonText: { + fontSize: 14, + color: '#666', + textAlign: 'center', + marginBottom: 24, + lineHeight: 20, + }, + comingSoonButton: { + minWidth: 120, + }, }); export default P2PScreen; diff --git a/mobile/src/screens/ReferralScreen.tsx b/mobile/src/screens/ReferralScreen.tsx index aac73623..d748d11d 100644 --- a/mobile/src/screens/ReferralScreen.tsx +++ b/mobile/src/screens/ReferralScreen.tsx @@ -1,4 +1,4 @@ -import React, { useState } from 'react'; +import React, { useState, useEffect } from 'react'; import { View, Text, @@ -13,6 +13,7 @@ import { } from 'react-native'; import { LinearGradient } from 'expo-linear-gradient'; import { useTranslation } from 'react-i18next'; +import { usePolkadot } from '../contexts/PolkadotContext'; import AppColors, { KurdistanColors } from '../theme/colors'; interface ReferralStats { @@ -32,12 +33,21 @@ interface Referral { const ReferralScreen: React.FC = () => { const { t } = useTranslation(); + const { selectedAccount, api, connectWallet } = usePolkadot(); const [isConnected, setIsConnected] = useState(false); - // Mock referral code - will be generated from blockchain - const referralCode = 'PZK-XYZABC123'; + // Check connection status + useEffect(() => { + setIsConnected(!!selectedAccount); + }, [selectedAccount]); + + // Generate referral code from wallet address + const referralCode = selectedAccount + ? `PZK-${selectedAccount.address.slice(0, 8).toUpperCase()}` + : 'PZK-CONNECT-WALLET'; // Mock stats - will be fetched from pallet_referral + // TODO: Fetch real stats from blockchain const stats: ReferralStats = { totalReferrals: 0, activeReferrals: 0, @@ -46,12 +56,20 @@ const ReferralScreen: React.FC = () => { }; // Mock referrals - will be fetched from blockchain + // TODO: Query pallet-trust or referral pallet for actual referrals const referrals: Referral[] = []; - const handleConnectWallet = () => { - // TODO: Implement Polkadot.js wallet connection - setIsConnected(true); - Alert.alert('Connected', 'Your wallet has been connected to the referral system!'); + const handleConnectWallet = async () => { + try { + await connectWallet(); + if (selectedAccount) { + setIsConnected(true); + Alert.alert('Connected', 'Your wallet has been connected to the referral system!'); + } + } catch (error) { + if (__DEV__) console.error('Wallet connection error:', error); + Alert.alert('Error', 'Failed to connect wallet. Please try again.'); + } }; const handleCopyCode = () => { @@ -67,10 +85,10 @@ const ReferralScreen: React.FC = () => { }); if (result.action === Share.sharedAction) { - console.log('Shared successfully'); + if (__DEV__) console.log('Shared successfully'); } } catch (error) { - console.error('Error sharing:', error); + if (__DEV__) console.error('Error sharing:', error); } }; diff --git a/mobile/src/screens/SignInScreen.tsx b/mobile/src/screens/SignInScreen.tsx index a39ab846..9c760c37 100644 --- a/mobile/src/screens/SignInScreen.tsx +++ b/mobile/src/screens/SignInScreen.tsx @@ -10,9 +10,12 @@ import { Platform, ScrollView, StatusBar, + Alert, + ActivityIndicator, } from 'react-native'; import { LinearGradient } from 'expo-linear-gradient'; import { useTranslation } from 'react-i18next'; +import { useAuth } from '../contexts/AuthContext'; import AppColors, { KurdistanColors } from '../theme/colors'; interface SignInScreenProps { @@ -22,13 +25,35 @@ interface SignInScreenProps { const SignInScreen: React.FC = ({ onSignIn, onNavigateToSignUp }) => { const { t } = useTranslation(); + const { signIn } = useAuth(); const [email, setEmail] = useState(''); const [password, setPassword] = useState(''); + const [isLoading, setIsLoading] = useState(false); - const handleSignIn = () => { - // TODO: Implement actual authentication - console.log('Sign in:', { email, password }); - onSignIn(); + const handleSignIn = async () => { + if (!email || !password) { + Alert.alert('Error', 'Please enter both email and password'); + return; + } + + setIsLoading(true); + + try { + const { error } = await signIn(email, password); + + if (error) { + Alert.alert('Sign In Failed', error.message); + return; + } + + // Success - navigate to app + onSignIn(); + } catch (error) { + Alert.alert('Error', 'An unexpected error occurred'); + if (__DEV__) console.error('Sign in error:', error); + } finally { + setIsLoading(false); + } }; return ( @@ -91,11 +116,16 @@ const SignInScreen: React.FC = ({ onSignIn, onNavigateToSignU - {t('auth.signIn')} + {isLoading ? ( + + ) : ( + {t('auth.signIn')} + )} @@ -223,6 +253,9 @@ const styles = StyleSheet.create({ fontWeight: 'bold', color: KurdistanColors.spi, }, + buttonDisabled: { + opacity: 0.6, + }, divider: { flexDirection: 'row', alignItems: 'center', diff --git a/mobile/src/screens/SignUpScreen.tsx b/mobile/src/screens/SignUpScreen.tsx index 80a406f6..0ce0d582 100644 --- a/mobile/src/screens/SignUpScreen.tsx +++ b/mobile/src/screens/SignUpScreen.tsx @@ -10,9 +10,12 @@ import { Platform, ScrollView, StatusBar, + Alert, + ActivityIndicator, } from 'react-native'; import { LinearGradient } from 'expo-linear-gradient'; import { useTranslation } from 'react-i18next'; +import { useAuth } from '../contexts/AuthContext'; import AppColors, { KurdistanColors } from '../theme/colors'; interface SignUpScreenProps { @@ -22,18 +25,42 @@ interface SignUpScreenProps { const SignUpScreen: React.FC = ({ onSignUp, onNavigateToSignIn }) => { const { t } = useTranslation(); + const { signUp } = useAuth(); const [email, setEmail] = useState(''); const [password, setPassword] = useState(''); const [confirmPassword, setConfirmPassword] = useState(''); + const [username, setUsername] = useState(''); + const [isLoading, setIsLoading] = useState(false); - const handleSignUp = () => { - // TODO: Implement actual registration - if (password !== confirmPassword) { - alert('Passwords do not match!'); + const handleSignUp = async () => { + if (!email || !password || !username) { + Alert.alert('Error', 'Please fill all required fields'); return; } - console.log('Sign up:', { email, password }); - onSignUp(); + + if (password !== confirmPassword) { + Alert.alert('Error', 'Passwords do not match'); + return; + } + + setIsLoading(true); + + try { + const { error } = await signUp(email, password, username); + + if (error) { + Alert.alert('Sign Up Failed', error.message); + return; + } + + // Success - navigate to app + onSignUp(); + } catch (error) { + Alert.alert('Error', 'An unexpected error occurred'); + if (__DEV__) console.error('Sign up error:', error); + } finally { + setIsLoading(false); + } }; return ( @@ -77,6 +104,18 @@ const SignUpScreen: React.FC = ({ onSignUp, onNavigateToSignI /> + + {t('auth.username')} + + + {t('auth.password')} = ({ onSignUp, onNavigateToSignI - {t('auth.signUp')} + {isLoading ? ( + + ) : ( + {t('auth.signUp')} + )} @@ -226,6 +270,9 @@ const styles = StyleSheet.create({ fontWeight: 'bold', color: KurdistanColors.spi, }, + buttonDisabled: { + opacity: 0.6, + }, divider: { flexDirection: 'row', alignItems: 'center', diff --git a/mobile/src/screens/StakingScreen.tsx b/mobile/src/screens/StakingScreen.tsx index c7441135..7be652dd 100644 --- a/mobile/src/screens/StakingScreen.tsx +++ b/mobile/src/screens/StakingScreen.tsx @@ -122,7 +122,7 @@ export default function StakingScreen() { estimatedAPY, }); } catch (error) { - console.error('Error fetching staking data:', error); + if (__DEV__) console.error('Error fetching staking data:', error); Alert.alert('Error', 'Failed to load staking data'); } finally { setLoading(false); @@ -155,7 +155,7 @@ export default function StakingScreen() { } }); } catch (error: any) { - console.error('Staking error:', error); + if (__DEV__) console.error('Staking error:', error); Alert.alert('Error', error.message || 'Failed to stake tokens'); } finally { setProcessing(false); @@ -189,7 +189,7 @@ export default function StakingScreen() { } }); } catch (error: any) { - console.error('Unstaking error:', error); + if (__DEV__) console.error('Unstaking error:', error); Alert.alert('Error', error.message || 'Failed to unstake tokens'); } finally { setProcessing(false); diff --git a/mobile/src/screens/SwapScreen.tsx b/mobile/src/screens/SwapScreen.tsx index b5fe9dcc..e038a58c 100644 --- a/mobile/src/screens/SwapScreen.tsx +++ b/mobile/src/screens/SwapScreen.tsx @@ -98,7 +98,7 @@ const SwapScreen: React.FC = () => { newBalances[token.symbol] = '0.0000'; } } catch (error) { - console.log(`No balance for ${token.symbol}`); + if (__DEV__) console.log(`No balance for ${token.symbol}`); newBalances[token.symbol] = '0.0000'; } } @@ -106,7 +106,7 @@ const SwapScreen: React.FC = () => { setBalances(newBalances); } catch (error) { - console.error('Failed to fetch balances:', error); + if (__DEV__) console.error('Failed to fetch balances:', error); } }, [api, isApiReady, selectedAccount]); @@ -159,7 +159,7 @@ const SwapScreen: React.FC = () => { setPoolReserves({ reserve1, reserve2 }); setState((prev) => ({ ...prev, loading: false })); } catch (error) { - console.error('Failed to fetch pool reserves:', error); + if (__DEV__) console.error('Failed to fetch pool reserves:', error); Alert.alert('Error', 'Failed to fetch pool information.'); setState((prev) => ({ ...prev, loading: false })); } @@ -214,7 +214,7 @@ const SwapScreen: React.FC = () => { setState((prev) => ({ ...prev, toAmount: toAmountFormatted })); setPriceImpact(impact); } catch (error) { - console.error('Calculation error:', error); + if (__DEV__) console.error('Calculation error:', error); setState((prev) => ({ ...prev, toAmount: '' })); } }, [state.fromAmount, state.fromToken, state.toToken, poolReserves]); @@ -326,12 +326,14 @@ const SwapScreen: React.FC = () => { // Create swap path const path = [state.fromToken.assetId, state.toToken.assetId]; - console.log('Swap params:', { - path, - amountIn, - amountOutMin, - slippage: state.slippage, - }); + if (__DEV__) { + console.log('Swap params:', { + path, + amountIn, + amountOutMin, + slippage: state.slippage, + }); + } // Create transaction const tx = api.tx.assetConversion.swapTokensForExactTokens( @@ -347,7 +349,7 @@ const SwapScreen: React.FC = () => { let unsub: (() => void) | undefined; tx.signAndSend(keyPair, ({ status, events, dispatchError }) => { - console.log('Transaction status:', status.type); + if (__DEV__) console.log('Transaction status:', status.type); if (dispatchError) { if (dispatchError.isModule) { @@ -365,7 +367,7 @@ const SwapScreen: React.FC = () => { } if (status.isInBlock || status.isFinalized) { - console.log('Transaction included in block'); + if (__DEV__) console.log('Transaction included in block'); resolve(); if (unsub) unsub(); } @@ -398,7 +400,7 @@ const SwapScreen: React.FC = () => { ] ); } catch (error: any) { - console.error('Swap failed:', error); + if (__DEV__) console.error('Swap failed:', error); Alert.alert('Swap Failed', error.message || 'An error occurred.'); setState((prev) => ({ ...prev, swapping: false })); } diff --git a/mobile/src/screens/WalletScreen.tsx b/mobile/src/screens/WalletScreen.tsx index 72622b26..2a5eb154 100644 --- a/mobile/src/screens/WalletScreen.tsx +++ b/mobile/src/screens/WalletScreen.tsx @@ -149,7 +149,7 @@ const WalletScreen: React.FC = () => { } } } catch (err) { - console.log('PEZ asset not found or not accessible'); + if (__DEV__) console.log('PEZ asset not found or not accessible'); } // Fetch USDT balance (wUSDT - asset ID 2) @@ -163,7 +163,7 @@ const WalletScreen: React.FC = () => { } } } catch (err) { - console.log('USDT asset not found or not accessible'); + if (__DEV__) console.log('USDT asset not found or not accessible'); } setBalances({ @@ -172,7 +172,7 @@ const WalletScreen: React.FC = () => { USDT: usdtBalance, }); } catch (err) { - console.error('Failed to fetch balances:', err); + if (__DEV__) console.error('Failed to fetch balances:', err); Alert.alert('Error', 'Failed to fetch token balances'); } finally { setIsLoadingBalances(false); @@ -198,7 +198,7 @@ const WalletScreen: React.FC = () => { await connectWallet(); Alert.alert('Connected', 'Wallet connected successfully!'); } catch (err) { - console.error('Failed to connect wallet:', err); + if (__DEV__) console.error('Failed to connect wallet:', err); Alert.alert('Error', 'Failed to connect wallet'); } }; @@ -220,7 +220,7 @@ const WalletScreen: React.FC = () => { [{ text: 'OK', onPress: () => connectWallet() }] ); } catch (err) { - console.error('Failed to create wallet:', err); + if (__DEV__) console.error('Failed to create wallet:', err); Alert.alert('Error', 'Failed to create wallet'); } };