From 667ba4be139b50b7bf12757f97a138e9a9902b70 Mon Sep 17 00:00:00 2001 From: Anshika Rai Date: Wed, 20 May 2026 17:57:18 +0530 Subject: [PATCH] feat: add vCard (.vcf) export support for DevCard profiles --- README.md | 28 ++++ .../src/components/SaveContactButton.tsx | 147 ++++++++++++++++++ apps/mobile/src/screens/DevCardViewScreen.tsx | 4 + apps/mobile/src/utils/vcard.ts | 52 +++++++ .../lib/components/SaveContactButton.svelte | 134 ++++++++++++++++ apps/web/src/lib/index.ts | 1 + apps/web/src/routes/u/[username]/+page.svelte | 3 + packages/shared/src/index.ts | 1 + packages/shared/src/vcard.test.ts | 121 ++++++++++++++ packages/shared/src/vcard.ts | 137 ++++++++++++++++ 10 files changed, 628 insertions(+) create mode 100644 apps/mobile/src/components/SaveContactButton.tsx create mode 100644 apps/mobile/src/utils/vcard.ts create mode 100644 apps/web/src/lib/components/SaveContactButton.svelte create mode 100644 packages/shared/src/vcard.test.ts create mode 100644 packages/shared/src/vcard.ts diff --git a/README.md b/README.md index 136600f..2723bf9 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,7 @@ 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) @@ -48,6 +49,7 @@ Each exchange is manual, error-prone, and slow. DevCard fixes this. - 🔒 **Privacy-First** — No tracking, no data selling, your data stays yours - 🛠️ **Open Source** — Apache 2.0 licensed, community-governed + ## Quick Start ### Prerequisites @@ -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). @@ -282,3 +309,4 @@ DevCard is licensed under the [Apache License 2.0](./LICENSE).

Built with ❤️ by the developer community, for the developer community.

+ diff --git a/apps/mobile/src/components/SaveContactButton.tsx b/apps/mobile/src/components/SaveContactButton.tsx new file mode 100644 index 0000000..98f0be2 --- /dev/null +++ b/apps/mobile/src/components/SaveContactButton.tsx @@ -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 ( + + {loading ? ( + + + Saving... + + ) : ( + + 📥 + Save Contact + + )} + + ); +} + +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, + }, +}); diff --git a/apps/mobile/src/screens/DevCardViewScreen.tsx b/apps/mobile/src/screens/DevCardViewScreen.tsx index 46cf951..61f14e6 100644 --- a/apps/mobile/src/screens/DevCardViewScreen.tsx +++ b/apps/mobile/src/screens/DevCardViewScreen.tsx @@ -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'; @@ -284,6 +285,9 @@ export default function DevCardViewScreen({ navigation, route }: Props) { + {/* Save Contact Action */} + + {/* Platform Tiles Section */} Digital Touchpoints diff --git a/apps/mobile/src/utils/vcard.ts b/apps/mobile/src/utils/vcard.ts new file mode 100644 index 0000000..8d83c61 --- /dev/null +++ b/apps/mobile/src/utils/vcard.ts @@ -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; +} diff --git a/apps/web/src/lib/components/SaveContactButton.svelte b/apps/web/src/lib/components/SaveContactButton.svelte new file mode 100644 index 0000000..85d3301 --- /dev/null +++ b/apps/web/src/lib/components/SaveContactButton.svelte @@ -0,0 +1,134 @@ + + + + +{#if errorMsg} + +{/if} + + diff --git a/apps/web/src/lib/index.ts b/apps/web/src/lib/index.ts index 856f2b6..c3e8db5 100644 --- a/apps/web/src/lib/index.ts +++ b/apps/web/src/lib/index.ts @@ -1 +1,2 @@ // place files you want to import through the `$lib` alias in this folder. +export { default as SaveContactButton } from './components/SaveContactButton.svelte'; diff --git a/apps/web/src/routes/u/[username]/+page.svelte b/apps/web/src/routes/u/[username]/+page.svelte index bb23cca..564f4b0 100644 --- a/apps/web/src/routes/u/[username]/+page.svelte +++ b/apps/web/src/routes/u/[username]/+page.svelte @@ -1,5 +1,6 @@