Skip to content
82 changes: 82 additions & 0 deletions apps/mobile/src/components/ProfileLink.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
// components/ProfileLink.tsx

import React from 'react';
import {
Linking,
Pressable,
Text,
View,
StyleSheet,
} from 'react-native';
import {
COLORS,
SPACING,
FONT_SIZE,
BORDER_RADIUS,
} from '../theme/tokens';
type ProfileLinkProps = {
platform: string;
username: string;
url: string;
onPress?: () => void;
};

export default function ProfileLink({
platform,
username,
url,
onPress,
}: ProfileLinkProps) {
const handlePress = async () => {
if (onPress) {
onPress();
return;
}

try {
await Linking.openURL(url);
} catch (error) {
console.warn('Failed to open profile link:', error);
}
Comment on lines +30 to +40

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

};

return (
<Pressable style={styles.card} onPress={handlePress}>
<View>
<Text style={styles.platform}>{platform}</Text>
<Text style={styles.username}>{username}</Text>
</View>

<Text style={styles.link}>Open</Text>
</Pressable>
Comment on lines +24 to +51
Comment on lines +24 to +51
);
Comment on lines +24 to +52
}

const styles = StyleSheet.create({
card: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
padding: SPACING.md,
borderRadius: BORDER_RADIUS.md,
backgroundColor: COLORS.bgCard,
},

platform: {
fontSize: FONT_SIZE.md,
fontWeight: '600',
color: COLORS.textPrimary,
},

username: {
marginTop: SPACING.xs,
fontSize: FONT_SIZE.sm,
color: COLORS.textMuted,
},

link: {
fontSize: FONT_SIZE.sm,
fontWeight: '600',
color: COLORS.primary,
},
});
35 changes: 35 additions & 0 deletions apps/mobile/src/components/__tests__/ProfileLink.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import React from 'react';
import { render, fireEvent } from '@testing-library/react-native';
import ProfileLink from '../ProfileLink';

describe('ProfileLink', () => {
it('renders platform and username', () => {
const { getByText } = render(
<ProfileLink
platform="GitHub"
username="@saurav"
url="https://github.com/saurav"
/>
);

expect(getByText('GitHub')).toBeTruthy();
expect(getByText('@saurav')).toBeTruthy();
});

it('calls custom onPress handler when provided', () => {
const mockPress = jest.fn();

const { getByText } = render(
<ProfileLink
platform="GitHub"
username="@saurav"
url="https://github.com/saurav"
onPress={mockPress}
/>
);

fireEvent.press(getByText('Open'));

expect(mockPress).toHaveBeenCalledTimes(1);
});
});
162 changes: 35 additions & 127 deletions apps/mobile/src/screens/HomeScreen.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React, { useState, useEffect, useCallback } from 'react';
import React, { useState, useEffect } from 'react';
import {
View,
Text,
Expand All @@ -9,9 +9,7 @@ import {
StatusBar,
Image,
RefreshControl,
TextInput,
} from 'react-native';
import { Skeleton } from '../components/Skeleton';
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';
Expand All @@ -20,7 +18,7 @@ import { PLATFORMS } from '@devcard/shared';
import { APP_URL, API_BASE_URL } from '../config';
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
import type { RootStackParamList } from '../navigation/MainTabs';

import ProfileLink from '../components/ProfileLink';
type Props = {
navigation: NativeStackNavigationProp<RootStackParamList>;
};
Expand All @@ -39,15 +37,16 @@ export default function HomeScreen({ navigation }: Props) {
const [analytics, setAnalytics] = useState<any>(null);
const [showQR, setShowQR] = useState(false);
const [refreshing, setRefreshing] = useState(false);
const [loading, setLoading] = useState(true);
const [searchUsername, setSearchUsername] = useState('');

const profileUrl = user?.defaultCardId
const profileUrl = user?.defaultCardId
? `${APP_URL}/devcard/${user.defaultCardId}`
: `${APP_URL}/u/${user?.username}`;

const fetchData = useCallback(async () => {
setLoading(true);
useEffect(() => {
fetchData();
}, []);

const fetchData = async () => {
try {
Comment on lines +45 to 50
const [profileRes, analyticsRes] = await Promise.all([
fetch(`${API_BASE_URL}/api/profiles/me`, {
Expand All @@ -67,14 +66,8 @@ export default function HomeScreen({ navigation }: Props) {
}
} catch (err) {
console.error('Failed to fetch dashboard data:', err);
} finally {
setLoading(false);
}
}, [token]);

useEffect(() => {
fetchData();
}, [fetchData]);
};

const onRefresh = async () => {
setRefreshing(true);
Expand All @@ -93,21 +86,6 @@ export default function HomeScreen({ navigation }: Props) {
}
};

if (loading) {
return (
<SafeAreaView style={styles.container}>
<StatusBar barStyle="light-content" backgroundColor={COLORS.bgPrimary} />
<View style={styles.loadingRoot}>
<Skeleton width={140} height={28} borderRadius={12} />
<Skeleton width="75%" height={20} borderRadius={12} style={styles.loadingSpacer} />
<Skeleton width="100%" height={180} borderRadius={24} style={styles.loadingSection} />
<Skeleton width="100%" height={120} borderRadius={24} style={styles.loadingSection} />
<Skeleton width="100%" height={92} borderRadius={24} style={styles.loadingSection} />
</View>
</SafeAreaView>
);
}

return (
<SafeAreaView style={styles.container}>
<StatusBar barStyle="light-content" backgroundColor={COLORS.bgPrimary} />
Expand Down Expand Up @@ -156,29 +134,29 @@ export default function HomeScreen({ navigation }: Props) {
{user?.bio && <Text style={styles.bio}>{user.bio}</Text>}

{/* Platform Links Summary */}
<View style={styles.linksSummary}>
{links.length > 0 ? (
<>
{links.slice(0, 4).map(link => {
const platform = PLATFORMS[link.platform];
return (
<View key={link.id} style={styles.linkBadge}>
<Text style={styles.linkBadgeText}>
{platform?.name || link.platform}
</Text>
</View>
);
})}
{links.length > 4 && (
<View style={styles.linkBadge}>
<Text style={styles.linkBadgeText}>+{links.length - 4}</Text>
</View>
)}
</>
) : (
<Text style={styles.emptyHint}>No platform links added yet. Add links in the Links tab to populate your preview.</Text>
)}
{/* Platform Links */}
<View style={styles.linksContainer}>
{links.slice(0, 4).map(link => {
const platform = PLATFORMS[link.platform];

return (
<ProfileLink
key={link.id}
platform={platform?.name || link.platform}
username={link.username}
url={link.url}
/>
Comment on lines +143 to +148
);
Comment on lines +142 to +149
})}
</View>
Comment on lines +137 to 151
Comment on lines +138 to 151

{links.length > 4 && (
<View style={styles.linkBadge}>
<Text style={styles.linkBadgeText}>
+{links.length - 4}
</Text>
</View>
)}
</View>

{/* QR Code Section */}
Expand Down Expand Up @@ -231,36 +209,6 @@ export default function HomeScreen({ navigation }: Props) {
</TouchableOpacity>
</View>

{/* Search / Lookup */}
<View style={styles.searchSection}>
<Text style={styles.searchLabel}>🔍 View a DevCard</Text>
<View style={styles.searchRow}>
<TextInput
style={styles.searchInput}
placeholder="Enter username..."
placeholderTextColor={COLORS.textMuted}
value={searchUsername}
onChangeText={setSearchUsername}
autoCapitalize="none"
autoCorrect={false}
returnKeyType="go"
onSubmitEditing={() => {
const u = searchUsername.trim();
if (u) (navigation as any).navigate('DevCardView', { username: u });
}}
/>
<TouchableOpacity
style={styles.searchBtn}
onPress={() => {
const u = searchUsername.trim();
if (u) (navigation as any).navigate('DevCardView', { username: u });
}}
>
<Text style={styles.searchBtnText}>Go →</Text>
</TouchableOpacity>
</View>
</View>

{/* Stats */}
<View style={styles.stats}>
<View style={styles.statItem}>
Expand Down Expand Up @@ -357,48 +305,8 @@ const styles = StyleSheet.create({
statNumber: { fontSize: FONT_SIZE.xl, fontWeight: '800', color: COLORS.primary },
statLabel: { fontSize: FONT_SIZE.xs, color: COLORS.textMuted, marginTop: 4 },
statDivider: { width: 1, backgroundColor: COLORS.border },
loadingRoot: {
flex: 1,
padding: SPACING.lg,
backgroundColor: COLORS.bgPrimary,
},
loadingSpacer: {
marginTop: SPACING.sm,
},
loadingSection: {
marginTop: SPACING.lg,
},
emptyHint: {
color: COLORS.textMuted,
fontSize: FONT_SIZE.sm,
lineHeight: 20,
marginTop: SPACING.sm,
maxWidth: '70%',
},
// Search
searchSection: {
marginBottom: SPACING.lg,
},
searchLabel: {
fontSize: FONT_SIZE.sm, fontWeight: '700', color: COLORS.textSecondary,
marginBottom: SPACING.sm, letterSpacing: 0.3,
},
searchRow: {
flexDirection: 'row', gap: SPACING.sm,
},
searchInput: {
flex: 1,
backgroundColor: COLORS.bgCard,
borderRadius: BORDER_RADIUS.md,
paddingHorizontal: SPACING.md, paddingVertical: 12,
color: COLORS.textPrimary, fontSize: FONT_SIZE.md,
borderWidth: 1, borderColor: COLORS.border,
},
searchBtn: {
backgroundColor: COLORS.primary,
borderRadius: BORDER_RADIUS.md,
paddingHorizontal: SPACING.lg,
justifyContent: 'center', alignItems: 'center',
},
searchBtnText: { color: COLORS.white, fontWeight: '700', fontSize: FONT_SIZE.md },
linksContainer: {
marginTop: SPACING.md,
gap: SPACING.sm,
},
Comment on lines +309 to +311

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Comment on lines +309 to +311
Comment on lines +309 to +311
});