Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,13 +41,15 @@ Each exchange is manual, error-prone, and slow. DevCard fixes this.
- 🔗 **Universal Profile Aggregation** — GitHub, LinkedIn, Twitter/X, GitLab, Devfolio, and 10+ more platforms
- 📱 **QR Code Sharing** — Show your QR, they scan, done
- ⚡ **One-Screen Multi-Platform Connect** — Follow on GitHub, Connect on LinkedIn, all from one card
- 📥 **vCard Export & Contact Saving** — Save developer contact cards (.vcf) natively with one tap to your phone book
- 📈 **Advanced Analytics** — Track who viewed your card, when, and from where (Web, QR, App)
- 🔌 **Per-Platform OAuth Integrations** — Securely connect accounts for "Silent Follows"
- 🎯 **Context Cards** — Different cards for different situations (Professional, Hackathon, Community)
- 🌐 **Web Backup** — Receivers don't need the app — works in any browser
- 🔒 **Privacy-First** — No tracking, no data selling, your data stays yours
- 🛠️ **Open Source** — Apache 2.0 licensed, community-governed


## Quick Start

### Prerequisites
Expand Down Expand Up @@ -273,6 +275,31 @@ New to open source? We've got you covered! Check out our [Good First Issues](htt

See [CONTRIBUTING.md](./CONTRIBUTING.md) for setup instructions, coding standards, and PR process.

## vCard Export Support

DevCard supports exporting contact cards in the industry-standard `.vcf` (vCard 3.0) format directly from both Svelte web and React Native mobile applications. This allows seamless integration with native OS contact books.

### Supported Fields

The shared generator maps the following profile attributes dynamically:
- **Full Name (`FN` / `N`)**: Parsed and split into first and last names.
- **Organization (`ORG`)**: Maps to the user's `company`.
- **Title (`TITLE`)**: Maps to the user's `role`.
- **Email (`EMAIL`)**: Automatically checks and extracts public email from profile settings and connected platform links.
- **Phone (`TEL`)**: Automatically parses public phone/WhatsApp URLs and numbers from custom touchpoints.
- **Bio/Notes (`NOTE`)**: Combines personal bio, pronouns, and role description, with proper character escaping.
- **Avatar (`PHOTO`)**: Embeds profile picture via high-performance native image URL loading.
- **Touchpoints (`URL`)**: Appends up to 10+ social/profile links as separate labelled URL entries.

### Platform Compatibility

Verified and tested on the following native contact managers and apps:
- **iOS Contacts** (Native contact save flow via Blob + Mobile Safari)
- **Android Contacts** (Native Google Contacts save flow via Mobile Chrome and Native Share Sheet)
- **Apple Contacts & macOS** (Seamless importing of `.vcf` files)
- **Google Contacts** (Standard desktop web and mobile compatibility)
- **Microsoft Outlook** (Clean parsing and field population)

## License

DevCard is licensed under the [Apache License 2.0](./LICENSE).
Expand All @@ -282,3 +309,4 @@ DevCard is licensed under the [Apache License 2.0](./LICENSE).
<p align="center">
Built with ❤️ by the developer community, for the developer community.
</p>

147 changes: 147 additions & 0 deletions apps/mobile/src/components/SaveContactButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
import React, { useState } from 'react';
import {
TouchableOpacity,
Text,
StyleSheet,
ActivityIndicator,
Share,
Platform,
Alert,
View,
} from 'react-native';
import { COLORS, SPACING, FONT_SIZE, BORDER_RADIUS, SHADOWS } from '../theme/tokens';
import { getProfileVCard, encodeBase64 } from '../utils/vcard';
import { APP_URL } from '../config';

interface PlatformLink {
id: string;
platform: string;
username: string;
url: string;
displayOrder: number;
}

interface ProfileData {
username: string;
displayName: string;
bio: string | null;
pronouns: string | null;
role: string | null;
company: string | null;
avatarUrl: string | null;
accentColor: string;
links: PlatformLink[];
}

interface SaveContactButtonProps {
profile: ProfileData;
}

export function SaveContactButton({ profile }: SaveContactButtonProps) {
const [loading, setLoading] = useState(false);

const handleSaveContact = async () => {
setLoading(true);
try {
const devcardUrl = `${APP_URL}/u/${profile.username}`;

const vcardContent = getProfileVCard({
displayName: profile.displayName,
username: profile.username,
bio: profile.bio,
pronouns: profile.pronouns,
role: profile.role,
company: profile.company,
avatarUrl: profile.avatarUrl,
links: profile.links,
devcardUrl,
});

if (Platform.OS === 'ios') {
const base64Vcf = encodeBase64(vcardContent);
const fileUri = `data:text/vcard;charset=utf-8;base64,${base64Vcf}`;

await Share.share({
url: fileUri,
message: `Save contact info for ${profile.displayName}`,
});
} else {
// Android and others: Share the vCard content directly as message text
await Share.share({
message: vcardContent,
title: `Save ${profile.displayName}'s Contact`,
});
}
} catch (err) {
console.error('Failed to share contact:', err);
Alert.alert('Error', 'Failed to export contact card.');
} finally {
setLoading(false);
}
};

const accentColor = profile.accentColor || COLORS.primary;

return (
<TouchableOpacity
style={[
styles.button,
{
borderColor: accentColor,
shadowColor: accentColor,
},
]}
onPress={handleSaveContact}
disabled={loading}
activeOpacity={0.8}
accessibilityRole="button"
accessibilityLabel={`Save ${profile.displayName}'s contact info to your phone`}
>
{loading ? (
<View style={styles.content}>
<ActivityIndicator size="small" color={COLORS.white} style={styles.spinner} />
<Text style={styles.text}>Saving...</Text>
</View>
) : (
<View style={styles.content}>
<Text style={styles.icon}>📥</Text>
<Text style={styles.text}>Save Contact</Text>
</View>
)}
</TouchableOpacity>
);
}

const styles = StyleSheet.create({
button: {
backgroundColor: 'rgba(255, 255, 255, 0.05)',
borderRadius: BORDER_RADIUS.md,
paddingVertical: SPACING.md,
paddingHorizontal: SPACING.lg,
borderWidth: 1.5,
alignItems: 'center',
justifyContent: 'center',
marginVertical: SPACING.md,
width: '100%',
...SHADOWS.button,
},
content: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
},
icon: {
fontSize: FONT_SIZE.md,
color: COLORS.white,
marginRight: SPACING.sm,
},
text: {
fontSize: FONT_SIZE.md,
fontWeight: '700',
color: COLORS.white,
letterSpacing: 0.5,
},
spinner: {
marginRight: SPACING.sm,
},
});
4 changes: 4 additions & 0 deletions apps/mobile/src/screens/DevCardViewScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
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 { SaveContactButton } from '../components/SaveContactButton';
import { PLATFORMS, getProfileUrl, getWebViewUrl } from '@devcard/shared';
import { API_BASE_URL } from '../config';
import { useAuth } from '../context/AuthContext';
Expand Down Expand Up @@ -284,6 +285,9 @@ export default function DevCardViewScreen({ navigation, route }: Props) {
</View>
</View>

{/* Save Contact Action */}
<SaveContactButton profile={profile} />

{/* Platform Tiles Section */}
<View style={styles.tilesSection}>
<Text style={styles.tilesLabel}>Digital Touchpoints</Text>
Expand Down
52 changes: 52 additions & 0 deletions apps/mobile/src/utils/vcard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
/* eslint-disable no-bitwise */
import { generateVCard, VCardInput } from '@devcard/shared';

/**
* Mobile-specific utility wrapper for generating vCard strings.
*/
export function getProfileVCard(profile: VCardInput): string {
return generateVCard(profile);
}

/**
* Encodes a string into Base64 format using a pure JavaScript implementation.
* Safe to use in any React Native environment without relying on Node.js Buffer or browser btoa.
*/
export function encodeBase64(str: string): string {
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
let result = '';
let i = 0;

// Convert string to UTF-8 byte array to preserve non-ASCII characters
const utf8Bytes: number[] = [];
for (let j = 0; j < str.length; j++) {
let c = str.charCodeAt(j);
if (c < 128) {
utf8Bytes.push(c);
} else if (c < 2048) {
utf8Bytes.push((c >> 6) | 192);
utf8Bytes.push((c & 63) | 128);
} else {
utf8Bytes.push((c >> 12) | 224);
utf8Bytes.push(((c >> 6) & 63) | 128);
utf8Bytes.push((c & 63) | 128);
}
}

const byteLength = utf8Bytes.length;
while (i < byteLength) {
const byte1 = utf8Bytes[i++];
const byte2 = i < byteLength ? utf8Bytes[i++] : NaN;
const byte3 = i < byteLength ? utf8Bytes[i++] : NaN;

const enc1 = byte1 >> 2;
const enc2 = ((byte1 & 3) << 4) | (isNaN(byte2) ? 0 : byte2 >> 4);
const enc3 = isNaN(byte2) ? 64 : ((byte2 & 15) << 2) | (isNaN(byte3) ? 0 : byte3 >> 6);
const enc4 = isNaN(byte3) ? 64 : byte3 & 63;

result += chars.charAt(enc1) + chars.charAt(enc2) +
(enc3 === 64 ? '=' : chars.charAt(enc3)) +
(enc4 === 64 ? '=' : chars.charAt(enc4));
}
return result;
}
Loading