From 90897e6546b77c982c83731916be7b8cd51d1fa9 Mon Sep 17 00:00:00 2001 From: Prashantkumar Khatri Date: Sat, 30 May 2026 12:26:32 +0530 Subject: [PATCH] feat: implement API service layer and refactor network requests across screens --- apps/mobile/src/components/Avatar.tsx | 37 +++++ apps/mobile/src/config.ts | 20 ++- apps/mobile/src/context/AuthContext.tsx | 20 +-- apps/mobile/src/navigation/MainTabs.tsx | 14 +- apps/mobile/src/screens/CardsScreen.tsx | 56 +++----- .../src/screens/ConnectPlatformsScreen.tsx | 21 +-- apps/mobile/src/screens/DevCardViewScreen.tsx | 135 ++++++------------ apps/mobile/src/screens/HomeScreen.tsx | 34 ++--- apps/mobile/src/screens/LinksScreen.tsx | 48 ++----- apps/mobile/src/screens/ScanScreen.tsx | 11 +- apps/mobile/src/screens/SettingsScreen.tsx | 47 ++---- apps/mobile/src/screens/ViewsScreen.tsx | 20 +-- apps/mobile/src/screens/WebViewScreen.tsx | 13 +- apps/mobile/src/services/api.ts | 46 ++++++ apps/mobile/src/utils/apiClient.ts | 37 +---- 15 files changed, 226 insertions(+), 333 deletions(-) create mode 100644 apps/mobile/src/components/Avatar.tsx create mode 100644 apps/mobile/src/services/api.ts diff --git a/apps/mobile/src/components/Avatar.tsx b/apps/mobile/src/components/Avatar.tsx new file mode 100644 index 00000000..8a0ee0c8 --- /dev/null +++ b/apps/mobile/src/components/Avatar.tsx @@ -0,0 +1,37 @@ +import React from 'react'; +import { View, Text, Image, ViewStyle, ImageStyle, StyleSheet } from 'react-native'; +import { COLORS } from '../theme/tokens'; + +type Props = { + uri?: string | null; + name?: string; + size?: number; + style?: ViewStyle | ImageStyle; +}; + +export const Avatar: React.FC = ({ uri, name = 'D', size = 56, style }) => { + const initials = name.charAt(0).toUpperCase(); + const imageStyle = [{ width: size, height: size, borderRadius: size / 2 } as ImageStyle, style as ImageStyle]; + const placeholderStyle = [{ width: size, height: size, borderRadius: size / 2, backgroundColor: COLORS.primary }, style as ViewStyle]; + + return uri ? ( + + ) : ( + + {initials} + + ); +}; + +export default Avatar; + +const styles = StyleSheet.create({ + placeholder: { + alignItems: 'center', + justifyContent: 'center', + }, + placeholderText: { + color: COLORS.white, + fontWeight: '800', + }, +}); diff --git a/apps/mobile/src/config.ts b/apps/mobile/src/config.ts index 460bf79f..49332a31 100644 --- a/apps/mobile/src/config.ts +++ b/apps/mobile/src/config.ts @@ -3,22 +3,18 @@ import * as Linking from 'expo-linking'; // DevCard API Configuration -const getDevServerHost = () => { - const constants = Constants as any; - const hostUri = - Constants.expoConfig?.hostUri || - constants.manifest2?.extra?.expoGo?.debuggerHost || - constants.manifest?.debuggerHost; +// Prefer explicit configuration via Expo/EAS extras. Fallback to sensible defaults +const extras = (Constants as any).manifest?.extra || (Constants as any).expoConfig?.extra; - return hostUri?.split(':')[0] || '10.155.14.65'; -}; +const DEV_API = extras?.API_BASE_URL || extras?.DEV_API_BASE_URL; +const DEV_APP = extras?.APP_URL; export const API_BASE_URL = __DEV__ - ? `http://${getDevServerHost()}:3000` - : 'https://api.devcard.dev'; + ? DEV_API ?? `http://10.0.2.2:3000` // 10.0.2.2 is a common emulator host for Android + : extras?.API_BASE_URL ?? 'https://api.devcard.dev'; export const APP_URL = __DEV__ - ? `http://${getDevServerHost()}:5173` - : 'https://devcard.dev'; + ? DEV_APP ?? `http://localhost:5173` + : extras?.APP_URL ?? 'https://devcard.dev'; export const OAUTH_REDIRECT_URI = Linking.createURL('oauth/callback'); diff --git a/apps/mobile/src/context/AuthContext.tsx b/apps/mobile/src/context/AuthContext.tsx index cf9d4b6d..109d0c5c 100644 --- a/apps/mobile/src/context/AuthContext.tsx +++ b/apps/mobile/src/context/AuthContext.tsx @@ -1,5 +1,5 @@ import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react'; -import { API_BASE_URL } from '../config'; +import { get } from '../services/api'; interface User { id: string; @@ -41,13 +41,8 @@ export function AuthProvider({ children }: { children: ReactNode }) { setToken(newToken); // TODO: Save token to secure storage try { - const res = await fetch(`${API_BASE_URL}/api/profiles/me`, { - headers: { Authorization: `Bearer ${newToken}` }, - }); - if (res.ok) { - const userData = await res.json(); - setUser(userData); - } + const userData = await get('/api/profiles/me', newToken).catch(() => null); + if (userData) setUser(userData); } catch (error) { console.error('Failed to fetch user:', error); } @@ -62,13 +57,8 @@ export function AuthProvider({ children }: { children: ReactNode }) { const refreshUser = async () => { if (!token) return; try { - const res = await fetch(`${API_BASE_URL}/api/profiles/me`, { - headers: { Authorization: `Bearer ${token}` }, - }); - if (res.ok) { - const userData = await res.json(); - setUser(userData); - } + const userData = await get('/api/profiles/me', token).catch(() => null); + if (userData) setUser(userData); } catch (error) { console.error('Failed to refresh user:', error); } diff --git a/apps/mobile/src/navigation/MainTabs.tsx b/apps/mobile/src/navigation/MainTabs.tsx index 203da2c2..6beb9cde 100644 --- a/apps/mobile/src/navigation/MainTabs.tsx +++ b/apps/mobile/src/navigation/MainTabs.tsx @@ -63,6 +63,14 @@ function TabIcon({ name, focused }: { name: string; focused: boolean }) { ); } +function ScanButton() { + return ( + + 📷 + + ); +} + // ─── Tab Navigator ─── const Tab = createBottomTabNavigator(); @@ -87,11 +95,7 @@ function TabNavigator() { component={ScanScreen} options={{ tabBarLabel: '', - tabBarIcon: () => ( - - 📷 - - ), + tabBarIcon: () => , }} /> diff --git a/apps/mobile/src/screens/CardsScreen.tsx b/apps/mobile/src/screens/CardsScreen.tsx index fbaf3c2f..6953ffd2 100644 --- a/apps/mobile/src/screens/CardsScreen.tsx +++ b/apps/mobile/src/screens/CardsScreen.tsx @@ -16,7 +16,7 @@ import { useFocusEffect } from '@react-navigation/native'; import { COLORS, SPACING, FONT_SIZE, BORDER_RADIUS, SHADOWS } from '../theme/tokens'; import { useAuth } from '../context/AuthContext'; import { PLATFORMS } from '@devcard/shared'; -import { API_BASE_URL } from '../config'; +import { get, post, del, put } from '../services/api'; import { EmptyState } from '../components/EmptyState'; import { Skeleton } from '../components/Skeleton'; @@ -46,19 +46,12 @@ export default function CardsScreen() { const fetchData = useCallback(async (showLoading = true) => { if (showLoading) setLoading(true); try { - const [cardsRes, profileRes] = await Promise.all([ - fetch(`${API_BASE_URL}/api/cards`, { - headers: { Authorization: `Bearer ${token}` }, - }), - fetch(`${API_BASE_URL}/api/profiles/me`, { - headers: { Authorization: `Bearer ${token}` }, - }), + const [cardsData, profileData] = await Promise.all([ + get('/api/cards', token).catch(() => []), + get('/api/profiles/me', token).catch(() => null), ]); - if (cardsRes.ok) setCards(await cardsRes.json()); - if (profileRes.ok) { - const data = await profileRes.json(); - setAllLinks(data.platformLinks || []); - } + setCards(cardsData || []); + setAllLinks(profileData?.platformLinks || []); } catch (error) { console.error('Failed to fetch:', error); } finally { @@ -84,20 +77,11 @@ export default function CardsScreen() { return; } try { - const res = await fetch(`${API_BASE_URL}/api/cards`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${token}`, - }, - body: JSON.stringify({ title: newTitle.trim(), linkIds: selectedLinkIds }), - }); - if (res.ok) { - setShowCreate(false); - setNewTitle(''); - setSelectedLinkIds([]); - fetchData(); - } + await post('/api/cards', { title: newTitle.trim(), linkIds: selectedLinkIds }, token); + setShowCreate(false); + setNewTitle(''); + setSelectedLinkIds([]); + fetchData(); } catch { Alert.alert('Error', 'Failed to create card'); } @@ -110,10 +94,11 @@ export default function CardsScreen() { text: 'Delete', style: 'destructive', onPress: async () => { - await fetch(`${API_BASE_URL}/api/cards/${id}`, { - method: 'DELETE', - headers: { Authorization: `Bearer ${token}` }, - }); + try { + await del(`/api/cards/${id}`, undefined, token); + } catch { + // ignore + } fetchData(); }, }, @@ -121,10 +106,11 @@ export default function CardsScreen() { }; const setDefault = async (id: string) => { - await fetch(`${API_BASE_URL}/api/cards/${id}/default`, { - method: 'PUT', - headers: { Authorization: `Bearer ${token}` }, - }); + try { + await put(`/api/cards/${id}/default`, undefined, token); + } catch { + // ignore + } fetchData(); }; diff --git a/apps/mobile/src/screens/ConnectPlatformsScreen.tsx b/apps/mobile/src/screens/ConnectPlatformsScreen.tsx index f2cf8dd2..2e59ed11 100644 --- a/apps/mobile/src/screens/ConnectPlatformsScreen.tsx +++ b/apps/mobile/src/screens/ConnectPlatformsScreen.tsx @@ -5,6 +5,7 @@ import Icon from 'react-native-vector-icons/MaterialCommunityIcons'; import { COLORS, SPACING, FONT_SIZE, BORDER_RADIUS } from '../theme/tokens'; import { useAuth } from '../context/AuthContext'; import { API_BASE_URL } from '../config'; +import { get, del } from '../services/api'; import { LoadingPlaceholder } from '../components/LoadingPlaceholder'; import type { NativeStackScreenProps } from '@react-navigation/native-stack'; import type { RootStackParamList } from '../navigation/MainTabs'; @@ -28,13 +29,8 @@ export const ConnectPlatformsScreen: React.FC = ({ navigation: _navigatio return; } try { - const response = await fetch(`${API_BASE_URL}/api/connect/status`, { - headers: { Authorization: `Bearer ${token}` }, - }); - if (response.ok) { - const data = await response.json(); - setConnectedPlatforms(data.connectedPlatforms || []); - } + const data = await get('/api/connect/status', token).catch(() => null); + setConnectedPlatforms(data?.connectedPlatforms || []); } catch (error) { console.error('Failed to fetch connected platforms', error); } finally { @@ -79,15 +75,8 @@ export const ConnectPlatformsScreen: React.FC = ({ navigation: _navigatio onPress: async () => { try { if (!token) return; - const response = await fetch(`${API_BASE_URL}/api/connect/${platform}`, { - method: 'DELETE', - headers: { Authorization: `Bearer ${token}` }, - }); - if (response.ok) { - fetchConnections(); - } else { - Alert.alert('Error', 'Failed to disconnect'); - } + await del(`/api/connect/${platform}`, undefined, token); + fetchConnections(); } catch { Alert.alert('Error', 'Failed to disconnect'); } diff --git a/apps/mobile/src/screens/DevCardViewScreen.tsx b/apps/mobile/src/screens/DevCardViewScreen.tsx index ced4d38f..becb878e 100644 --- a/apps/mobile/src/screens/DevCardViewScreen.tsx +++ b/apps/mobile/src/screens/DevCardViewScreen.tsx @@ -5,7 +5,6 @@ import { StyleSheet, ScrollView, TouchableOpacity, - Image, Linking, Clipboard, StatusBar, @@ -16,8 +15,9 @@ import { SafeAreaView } from 'react-native-safe-area-context'; import { COLORS, SPACING, FONT_SIZE, BORDER_RADIUS, SHADOWS } from '../theme/tokens'; import { Skeleton } from '../components/Skeleton'; import { EmptyState } from '../components/EmptyState'; +import Avatar from '../components/Avatar'; import { PLATFORMS, getProfileUrl, getWebViewUrl } from '@devcard/shared'; -import { API_BASE_URL } from '../config'; +import { get, post, del } from '../services/api'; import { useAuth } from '../context/AuthContext'; import type { NativeStackNavigationProp } from '@react-navigation/native-stack'; import type { RouteProp } from '@react-navigation/native'; @@ -99,20 +99,13 @@ export default function DevCardViewScreen({ navigation, route }: Props) { const fetchProfile = useCallback(async () => { try { - const headers: Record = {}; - if (token) { - headers['Authorization'] = `Bearer ${token}`; - } - const res = await fetch(`${API_BASE_URL}/api/u/${username}`, { headers }); - if (res.ok) { - const data = await res.json(); + const data = await get(`/api/u/${username}`, token); + if (data) { setProfile(data); const initialFollowStates: FollowState = {}; if (data.links) { data.links.forEach((link: any) => { - if (link.followed) { - initialFollowStates[link.id] = 'success'; - } + if (link.followed) initialFollowStates[link.id] = 'success'; }); } setFollowStates(initialFollowStates); @@ -150,30 +143,19 @@ export default function DevCardViewScreen({ navigation, route }: Props) { break; case 'webview': - setFollowStates(prev => ({ ...prev, [link.id]: 'loading' })); - try { - const res = await fetch( - `${API_BASE_URL}/api/follow/${link.platform}/${link.username}`, - { - method: 'POST', - headers: { Authorization: `Bearer ${token}` }, - } - ); - setFollowStates(prev => ({ ...prev, [link.id]: 'idle' })); - if (res.ok) { - const data = await res.json(); - if (data.strategy === 'webview') { + setFollowStates(prev => ({ ...prev, [link.id]: 'loading' })); + try { + const data = await post(`/api/follow/${link.platform}/${link.username}`, undefined, token); + setFollowStates(prev => ({ ...prev, [link.id]: 'idle' })); + if (data?.strategy === 'webview') { handleWebViewConnect(link, data.url); } else { setFollowStates(prev => ({ ...prev, [link.id]: 'success' })); } - } else { + } catch { + setFollowStates(prev => ({ ...prev, [link.id]: 'idle' })); handleWebViewConnect(link); } - } catch { - setFollowStates(prev => ({ ...prev, [link.id]: 'idle' })); - handleWebViewConnect(link); - } break; case 'copy': @@ -198,54 +180,31 @@ export default function DevCardViewScreen({ navigation, route }: Props) { const handleApiFollow = async (link: PlatformLink) => { setFollowStates(prev => ({ ...prev, [link.id]: 'loading' })); try { - const res = await fetch( - `${API_BASE_URL}/api/follow/${link.platform}/${link.username}`, - { - method: 'POST', - headers: { Authorization: `Bearer ${token}` }, - } - ); - if (res.ok) { - setFollowStates(prev => ({ ...prev, [link.id]: 'success' })); - } else { - const data = await res.json(); - if (data.requiresAuth) { - // Reset loading BEFORE opening fallback so button doesn't get stuck - setFollowStates(prev => ({ ...prev, [link.id]: 'idle' })); - // For platforms without a webview URL (e.g. GitHub), open in system browser - const webViewUrl = getWebViewUrl(link.platform, link.username); - if (webViewUrl) { - handleWebViewConnect(link); - } else { - // Open GitHub / other API-only platforms in the default browser - const profileUrl = link.url || getProfileUrl(link.platform, link.username); - if (profileUrl) { - Linking.openURL(profileUrl).catch(() => - Alert.alert('Error', `Could not open ${link.platform} profile`) - ); - } - } + await post(`/api/follow/${link.platform}/${link.username}`, undefined, token); + setFollowStates(prev => ({ ...prev, [link.id]: 'success' })); + } catch (err: any) { + const msg = (err && err.message) || ''; + if (msg.includes('requiresAuth')) { + setFollowStates(prev => ({ ...prev, [link.id]: 'idle' })); + const webViewUrl = getWebViewUrl(link.platform, link.username); + if (webViewUrl) { + handleWebViewConnect(link); } else { - setFollowStates(prev => ({ ...prev, [link.id]: 'error' })); + const profileUrl = link.url || getProfileUrl(link.platform, link.username); + if (profileUrl) Linking.openURL(profileUrl).catch(() => Alert.alert('Error', `Could not open ${link.platform} profile`)); } + } else { + setFollowStates(prev => ({ ...prev, [link.id]: 'error' })); } - } catch { - setFollowStates(prev => ({ ...prev, [link.id]: 'error' })); } }; // Reset a "Done" tile — clears follow log from backend and resets local state const handleResetFollowState = async (link: PlatformLink) => { try { - await fetch( - `${API_BASE_URL}/api/follow/${link.platform}/${link.username}/log`, - { - method: 'DELETE', - headers: { Authorization: `Bearer ${token}` }, - } - ); + await del(`/api/follow/${link.platform}/${link.username}/log`, undefined, token); } catch { - // Ignore network errors — still reset local state + // ignore } setFollowStates(prev => ({ ...prev, [link.id]: 'idle' })); }; @@ -299,14 +258,14 @@ export default function DevCardViewScreen({ navigation, route }: Props) { {/* Header Skeleton */} - + - + @@ -318,12 +277,12 @@ export default function DevCardViewScreen({ navigation, route }: Props) { {/* Tiles Skeleton */} - + {[1, 2, 3].map(i => ( - - + + @@ -379,15 +338,7 @@ export default function DevCardViewScreen({ navigation, route }: Props) { {/* Middle: avatar + name/role */} - {profile.avatarUrl ? ( - - ) : ( - - - {profile.displayName.charAt(0).toUpperCase()} - - - )} + {profile.displayName} @@ -432,6 +383,9 @@ export default function DevCardViewScreen({ navigation, route }: Props) { const state = followStates[link.id] || 'idle'; const btnColor = getButtonColor(link, state); const isDone = state === 'success'; + const tileIconDynamic = isDone + ? { backgroundColor: 'rgba(34,197,94,0.12)', borderColor: COLORS.success } + : { backgroundColor: (platform?.color || COLORS.primary) + '22', borderColor: (platform?.color || COLORS.primary) + '66' }; return ( {/* Icon */} - + {isDone ? ( ✓ ) : ( @@ -626,6 +569,10 @@ const styles = StyleSheet.create({ borderWidth: 1, borderColor: COLORS.border, }, + skelMb8: { marginBottom: 8 }, + skelMb12: { marginBottom: 12 }, + skelMb6: { marginBottom: 6 }, + tileInfoMl16: { marginLeft: 16 }, // ─── Error / Footer ─── errorState: { flex: 1, alignItems: 'center', justifyContent: 'center' }, diff --git a/apps/mobile/src/screens/HomeScreen.tsx b/apps/mobile/src/screens/HomeScreen.tsx index b8fe8068..0e25f365 100644 --- a/apps/mobile/src/screens/HomeScreen.tsx +++ b/apps/mobile/src/screens/HomeScreen.tsx @@ -7,17 +7,18 @@ import { TouchableOpacity, Share, StatusBar, - Image, RefreshControl, TextInput, } from 'react-native'; import { Skeleton } from '../components/Skeleton'; +import Avatar from '../components/Avatar'; import { SafeAreaView } from 'react-native-safe-area-context'; import QRCode from 'react-native-qrcode-svg'; import { COLORS, SPACING, FONT_SIZE, BORDER_RADIUS, SHADOWS } from '../theme/tokens'; import { useAuth } from '../context/AuthContext'; import { PLATFORMS } from '@devcard/shared'; -import { APP_URL, API_BASE_URL } from '../config'; +import { APP_URL } from '../config'; +import { get } from '../services/api'; import type { NativeStackNavigationProp } from '@react-navigation/native-stack'; import type { RootStackParamList } from '../navigation/MainTabs'; @@ -49,21 +50,16 @@ export default function HomeScreen({ navigation }: Props) { const fetchData = useCallback(async () => { setLoading(true); try { - const [profileRes, analyticsRes] = await Promise.all([ - fetch(`${API_BASE_URL}/api/profiles/me`, { - headers: { Authorization: `Bearer ${token}` }, - }), - fetch(`${API_BASE_URL}/api/analytics/overview`, { - headers: { Authorization: `Bearer ${token}` }, - }) + const [profileData, analyticsData] = await Promise.all([ + get('/api/profiles/me', token).catch(() => null), + get('/api/analytics/overview', token).catch(() => null), ]); - if (profileRes.ok) { - const data = await profileRes.json(); - setLinks(data.platformLinks || []); + if (profileData) { + setLinks(profileData.platformLinks || []); } - if (analyticsRes.ok) { - setAnalytics(await analyticsRes.json()); + if (analyticsData) { + setAnalytics(analyticsData); } } catch (error) { console.error('Failed to fetch dashboard data:', error); @@ -130,15 +126,7 @@ export default function HomeScreen({ navigation }: Props) { {/* Profile Card Preview */} - {user?.avatarUrl ? ( - - ) : ( - - - {(user?.displayName || 'D').charAt(0).toUpperCase()} - - - )} + {user?.displayName} {user?.pronouns && ( diff --git a/apps/mobile/src/screens/LinksScreen.tsx b/apps/mobile/src/screens/LinksScreen.tsx index 43be5e40..a2689a02 100644 --- a/apps/mobile/src/screens/LinksScreen.tsx +++ b/apps/mobile/src/screens/LinksScreen.tsx @@ -14,7 +14,7 @@ import { SafeAreaView } from 'react-native-safe-area-context'; import { COLORS, SPACING, FONT_SIZE, BORDER_RADIUS } from '../theme/tokens'; import { useAuth } from '../context/AuthContext'; import { PLATFORMS, getAllPlatforms } from '@devcard/shared'; -import { API_BASE_URL } from '../config'; +import { get, post, del } from '../services/api'; import { EmptyState } from '../components/EmptyState'; import { LoadingPlaceholder } from '../components/LoadingPlaceholder'; import type { PlatformDef } from '@devcard/shared'; @@ -38,13 +38,8 @@ export default function LinksScreen() { const fetchLinks = useCallback(async () => { setLoading(true); try { - const res = await fetch(`${API_BASE_URL}/api/profiles/me`, { - headers: { Authorization: `Bearer ${token}` }, - }); - if (res.ok) { - const data = await res.json(); - setLinks(data.platformLinks || []); - } + const data = await get('/api/profiles/me', token).catch(() => null); + setLinks(data?.platformLinks || []); } catch (error) { console.error('Failed to fetch links:', error); } finally { @@ -59,23 +54,11 @@ export default function LinksScreen() { const addLink = async () => { if (!selectedPlatform || !usernameInput.trim()) return; try { - const res = await fetch(`${API_BASE_URL}/api/profiles/me/links`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${token}`, - }, - body: JSON.stringify({ - platform: selectedPlatform.id, - username: usernameInput.trim(), - }), - }); - if (res.ok) { - setShowAddModal(false); - setSelectedPlatform(null); - setUsernameInput(''); - fetchLinks(); - } + await post('/api/profiles/me/links', { platform: selectedPlatform.id, username: usernameInput.trim() }, token); + setShowAddModal(false); + setSelectedPlatform(null); + setUsernameInput(''); + fetchLinks(); } catch { Alert.alert('Error', 'Failed to add link'); } @@ -88,15 +71,12 @@ export default function LinksScreen() { text: 'Remove', style: 'destructive', onPress: async () => { - try { - await fetch(`${API_BASE_URL}/api/profiles/me/links/${id}`, { - method: 'DELETE', - headers: { Authorization: `Bearer ${token}` }, - }); - fetchLinks(); - } catch { - Alert.alert('Error', 'Failed to remove link'); - } + try { + await del(`/api/profiles/me/links/${id}`, undefined, token); + fetchLinks(); + } catch { + Alert.alert('Error', 'Failed to remove link'); + } }, }, ]); diff --git a/apps/mobile/src/screens/ScanScreen.tsx b/apps/mobile/src/screens/ScanScreen.tsx index 48013248..468e8740 100644 --- a/apps/mobile/src/screens/ScanScreen.tsx +++ b/apps/mobile/src/screens/ScanScreen.tsx @@ -20,7 +20,8 @@ import type { RootStackParamList } from '../navigation/MainTabs'; import type { BottomSheetModal } from '@gorhom/bottom-sheet'; import type { Card } from '@devcard/shared'; import { useAuth } from '../context/AuthContext'; -import { API_BASE_URL, APP_URL } from '../config'; +import { APP_URL } from '../config'; +import { get } from '../services/api'; import CardPickerSheet from '../components/CardPickerSheet'; type Props = { @@ -64,12 +65,8 @@ export default function ScanScreen({ navigation }: Props) { if (!token) return; setLoadingCards(true); try { - const res = await fetch(`${API_BASE_URL}/api/cards`, { - headers: { Authorization: `Bearer ${token}` }, - }); - if (res.ok) { - setCards(await res.json()); - } + const data = await get('/api/cards', token).catch(() => []); + setCards(data || []); } catch (error) { console.error('Failed to fetch cards:', error); } finally { diff --git a/apps/mobile/src/screens/SettingsScreen.tsx b/apps/mobile/src/screens/SettingsScreen.tsx index 58f952f3..a8c07f31 100644 --- a/apps/mobile/src/screens/SettingsScreen.tsx +++ b/apps/mobile/src/screens/SettingsScreen.tsx @@ -8,15 +8,13 @@ import { TextInput, Alert, StatusBar, - Image, } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; import { useNavigation } from '@react-navigation/native'; import { COLORS, SPACING, FONT_SIZE, BORDER_RADIUS } from '../theme/tokens'; +import Avatar from '../components/Avatar'; import { useAuth } from '../context/AuthContext'; -import { API_BASE_URL } from '../config'; - -import { useNavigation } from '@react-navigation/native'; +import { put } from '../services/api'; export default function SettingsScreen() { const navigation = useNavigation(); @@ -31,26 +29,17 @@ export default function SettingsScreen() { const handleSave = async () => { setSaving(true); try { - const res = await fetch(`${API_BASE_URL}/api/profiles/me`, { - method: 'PUT', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${token}`, - }, - body: JSON.stringify({ - displayName: displayName.trim(), - bio: bio.trim() || null, - pronouns: pronouns.trim() || null, - role: role.trim() || null, - company: company.trim() || null, - }), - }); - if (res.ok) { - await refreshUser(); - Alert.alert('Success', 'Profile updated!'); - } else { - Alert.alert('Error', 'Failed to update profile'); - } + const payload = { + displayName: displayName.trim() || undefined, + bio: bio.trim() || null, + pronouns: pronouns.trim() || null, + role: role.trim() || null, + company: company.trim() || null, + }; + + await put('/api/profiles/me', payload, token); + await refreshUser(); + Alert.alert('Success', 'Profile updated!'); } catch { Alert.alert('Error', 'Something went wrong'); } finally { @@ -74,15 +63,7 @@ export default function SettingsScreen() { {/* Avatar */} - {user?.avatarUrl ? ( - - ) : ( - - - {(user?.displayName || 'D').charAt(0).toUpperCase()} - - - )} + @{user?.username} diff --git a/apps/mobile/src/screens/ViewsScreen.tsx b/apps/mobile/src/screens/ViewsScreen.tsx index d065eeab..2355d192 100644 --- a/apps/mobile/src/screens/ViewsScreen.tsx +++ b/apps/mobile/src/screens/ViewsScreen.tsx @@ -1,11 +1,12 @@ import React, { useState, useEffect, useCallback } from 'react'; -import { View, Text, StyleSheet, FlatList, Image } from 'react-native'; +import { View, Text, StyleSheet, FlatList } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; import Icon from 'react-native-vector-icons/MaterialCommunityIcons'; import { COLORS, SPACING, FONT_SIZE, BORDER_RADIUS } from '../theme/tokens'; import { useAuth } from '../context/AuthContext'; -import { API_BASE_URL } from '../config'; +import { get } from '../services/api'; import { EmptyState } from '../components/EmptyState'; +import Avatar from '../components/Avatar'; import { LoadingPlaceholder } from '../components/LoadingPlaceholder'; import type { NativeStackScreenProps } from '@react-navigation/native-stack'; import type { RootStackParamList } from '../navigation/MainTabs'; @@ -23,13 +24,8 @@ export const ViewsScreen: React.FC = () => { return; } try { - const response = await fetch(`${API_BASE_URL}/api/analytics/views`, { - headers: { Authorization: `Bearer ${token}` }, - }); - if (response.ok) { - const data = await response.json(); - setViews(data.data || []); - } + const data = await get('/api/analytics/views', token).catch(() => null); + setViews(data?.data || []); } catch (error) { console.error('Failed to fetch views analytics', error); } finally { @@ -66,11 +62,9 @@ export const ViewsScreen: React.FC = () => { ) : item.viewer.avatarUrl ? ( - + ) : ( - - {item.viewer.displayName.charAt(0)} - + )} diff --git a/apps/mobile/src/screens/WebViewScreen.tsx b/apps/mobile/src/screens/WebViewScreen.tsx index 018566d5..844c248a 100644 --- a/apps/mobile/src/screens/WebViewScreen.tsx +++ b/apps/mobile/src/screens/WebViewScreen.tsx @@ -12,7 +12,7 @@ import { WebView } from 'react-native-webview'; import { COLORS, SPACING, FONT_SIZE, BORDER_RADIUS, SHADOWS } from '../theme/tokens'; import { Skeleton } from '../components/Skeleton'; import { getDeepLinkUrl } from '@devcard/shared'; -import { API_BASE_URL } from '../config'; +import { post } from '../services/api'; import { useAuth } from '../context/AuthContext'; import type { NativeStackNavigationProp } from '@react-navigation/native-stack'; import type { RouteProp } from '@react-navigation/native'; @@ -121,14 +121,7 @@ export default function WebViewScreen({ navigation, route }: Props) { // Asynchronously log follow to the backend if (token && username) { try { - await fetch(`${API_BASE_URL}/api/follow/${platform}/${username}/log`, { - method: 'POST', - headers: { - Authorization: `Bearer ${token}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ status: 'success', layer: 'webview' }), - }); + await post(`/api/follow/${platform}/${username}/log`, { status: 'success', layer: 'webview' }, token); } catch (error) { console.warn('Failed to log WebView follow success:', error); } @@ -312,7 +305,7 @@ export default function WebViewScreen({ navigation, route }: Props) { var allEls = document.querySelectorAll('button, a, span, [role="button"], li'); for (var i = 0; i < allEls.length; i++) { var el = allEls[i]; - var text = (el.textContent || '').replace(/\s+/g, ' ').trim().toLowerCase(); + var text = (el.textContent || '').replace(new RegExp('\\s+', 'g'), ' ').trim().toLowerCase(); var aria = (el.getAttribute('aria-label') || '').toLowerCase(); var combined = text + ' ' + aria; for (var j = 0; j < SUCCESS_KEYWORDS.length; j++) { diff --git a/apps/mobile/src/services/api.ts b/apps/mobile/src/services/api.ts new file mode 100644 index 00000000..70daf195 --- /dev/null +++ b/apps/mobile/src/services/api.ts @@ -0,0 +1,46 @@ +import { API_BASE_URL } from '../config'; + +type RequestOptions = { + method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'; + body?: unknown; + token?: string | null; + onUnauthorized?: () => void; +}; + +export async function apiRequest( + path: string, + { method = 'GET', body, token, onUnauthorized }: RequestOptions = {} +): Promise { + const headers: Record = { + 'Content-Type': 'application/json', + ...(token ? { Authorization: `Bearer ${token}` } : {}), + }; + + const res = await fetch(`${API_BASE_URL}${path}`, { + method, + headers, + ...(body ? { body: JSON.stringify(body) } : {}), + }); + + if (res.status === 401 || res.status === 403) { + onUnauthorized?.(); + throw new Error('Unauthorized'); + } + + if (!res.ok) { + const err = await res.json().catch(() => ({})); + throw new Error((err as any)?.message ?? `Request failed: ${res.status}`); + } + + // Some endpoints may return empty responses + const text = await res.text(); + if (!text) return (null as unknown) as T; + return JSON.parse(text) as T; +} + +export const get = (path: string, token?: string | null) => apiRequest(path, { method: 'GET', token }); +export const post = (path: string, body?: unknown, token?: string | null) => apiRequest(path, { method: 'POST', body, token }); +export const put = (path: string, body?: unknown, token?: string | null) => apiRequest(path, { method: 'PUT', body, token }); +export const del = (path: string, body?: unknown, token?: string | null) => apiRequest(path, { method: 'DELETE', body, token }); + +export default { apiRequest, get, post, put, del }; diff --git a/apps/mobile/src/utils/apiClient.ts b/apps/mobile/src/utils/apiClient.ts index a4b78f05..4f2879a8 100644 --- a/apps/mobile/src/utils/apiClient.ts +++ b/apps/mobile/src/utils/apiClient.ts @@ -1,36 +1 @@ -import { API_BASE_URL } from '../config'; - -type RequestOptions = { - method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'; - body?: unknown; - token: string | null; - onUnauthorized?: () => void; -}; - -export async function apiRequest( - endpoint: string, - { method = 'GET', body, token, onUnauthorized }: RequestOptions -): Promise { - const headers: HeadersInit = { - 'Content-Type': 'application/json', - ...(token ? { Authorization: `Bearer ${token}` } : {}), - }; - - const response = await fetch(`${API_BASE_URL}${endpoint}`, { - method, - headers, - ...(body ? { body: JSON.stringify(body) } : {}), - }); - - if (response.status === 401 || response.status === 403) { - onUnauthorized?.(); - throw new Error('Unauthorized'); - } - - if (!response.ok) { - const error = await response.json().catch(() => ({})); - throw new Error(error?.message ?? `Request failed: ${response.status}`); - } - - return response.json() as Promise; -} \ No newline at end of file +export { apiRequest, get, post, put, del } from '../services/api'; \ No newline at end of file