β back to Costa Rica App
costa-rica-app: Contacts screen β import address book (expo-contacts + NSContactsUsageDescription), show who's on-app, invite the rest via WhatsApp; π₯ entry point on directory
2368baa32ce798ea2706ab277b131bd37da5de66 Β· 2026-08-07 10:30:28 -0700 Β· Steve Abrams
Files touched
M app.jsonM app/_layout.tsxA app/contacts.tsxM app/index.tsxM package.jsonM src/api.ts
Diff
commit 2368baa32ce798ea2706ab277b131bd37da5de66
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Fri Aug 7 10:30:28 2026 -0700
costa-rica-app: Contacts screen β import address book (expo-contacts + NSContactsUsageDescription), show who's on-app, invite the rest via WhatsApp; π₯ entry point on directory
---
app.json | 5 +--
app/_layout.tsx | 1 +
app/contacts.tsx | 94 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++
app/index.tsx | 4 +++
package.json | 1 +
src/api.ts | 4 +++
6 files changed, 107 insertions(+), 2 deletions(-)
diff --git a/app.json b/app.json
index ce74a36..2f3d0d3 100644
--- a/app.json
+++ b/app.json
@@ -13,14 +13,15 @@
"supportsTablet": true,
"infoPlist": {
"ITSAppUsesNonExemptEncryption": false,
- "NSLocationWhenInUseUsageDescription": "Show nearby stays, tours, and services on the map."
+ "NSLocationWhenInUseUsageDescription": "Show nearby stays, tours, and services on the map.",
+ "NSContactsUsageDescription": "Find which of your contacts are already here and invite the rest. Your contacts are only used for you β never shared or messaged by us."
}
},
"android": {
"package": "com.abrams.costarica",
"permissions": ["ACCESS_FINE_LOCATION"]
},
- "plugins": ["expo-router", "expo-secure-store", "expo-apple-authentication"],
+ "plugins": ["expo-router", "expo-secure-store", "expo-apple-authentication", "expo-contacts"],
"extra": {
"apiBase": "https://costarica.agentabrams.com",
"eas": { "projectId": "TO_BE_SET_BY_eas_init" }
diff --git a/app/_layout.tsx b/app/_layout.tsx
index 3f5be03..060b50d 100644
--- a/app/_layout.tsx
+++ b/app/_layout.tsx
@@ -14,6 +14,7 @@ export default function RootLayout() {
<Stack.Screen name="bookings" options={{ title: 'My Bookings' }} />
<Stack.Screen name="login" options={{ title: 'Sign in', presentation: 'modal' }} />
<Stack.Screen name="host" options={{ title: 'Become a Host' }} />
+ <Stack.Screen name="contacts" options={{ title: 'Invite friends' }} />
</Stack>
);
}
diff --git a/app/contacts.tsx b/app/contacts.tsx
new file mode 100644
index 0000000..02b0a4a
--- /dev/null
+++ b/app/contacts.tsx
@@ -0,0 +1,94 @@
+import { useEffect, useState } from 'react';
+import { View, Text, FlatList, TouchableOpacity, StyleSheet, Alert, ActivityIndicator, Linking } from 'react-native';
+import * as Contacts from 'expo-contacts';
+import { api, isAuthed } from '../src/api';
+import { router } from 'expo-router';
+
+export default function ContactsScreen() {
+ const [items, setItems] = useState<any[]>([]);
+ const [loading, setLoading] = useState(true);
+ const [summary, setSummary] = useState<{ imported: number; matched: number } | null>(null);
+
+ useEffect(() => {
+ if (!isAuthed()) { router.replace('/login'); return; }
+ refresh();
+ }, []);
+
+ async function refresh() {
+ setLoading(true);
+ try { const r = await api.contacts(); setItems(r.contacts || []); } catch {} finally { setLoading(false); }
+ }
+
+ // Ask permission, read the WHOLE address book, upload for matching.
+ async function importAll() {
+ const { status } = await Contacts.requestPermissionsAsync();
+ if (status !== 'granted') { Alert.alert('Permission needed', 'Enable Contacts access in Settings to invite friends.'); return; }
+ setLoading(true);
+ const { data } = await Contacts.getContactsAsync({ fields: [Contacts.Fields.PhoneNumbers, Contacts.Fields.Emails] });
+ const payload: any[] = [];
+ for (const c of data) {
+ for (const p of c.phoneNumbers || []) {
+ payload.push({ name: c.name, phone: p.number, email: c.emails?.[0]?.email });
+ }
+ }
+ try {
+ const r = await api.importContacts(payload);
+ setSummary({ imported: r.imported, matched: r.matched });
+ await refresh();
+ } catch (e: any) { Alert.alert('Import failed', e.message); setLoading(false); }
+ }
+
+ async function invite(c: any) {
+ try {
+ const r = await api.inviteContact(c.id);
+ Linking.openURL(r.invite_link); // opens WhatsApp with a prefilled invite
+ setItems(items.map(x => x.id === c.id ? { ...x, invited_at: new Date().toISOString() } : x));
+ } catch (e: any) { Alert.alert('Could not invite', e.message); }
+ }
+
+ return (
+ <View style={s.wrap}>
+ <View style={s.head}>
+ <Text style={s.h}>Invite friends</Text>
+ <Text style={s.sub}>See who's already here and invite the rest over WhatsApp. Your contacts are only used for you.</Text>
+ <TouchableOpacity style={s.cta} onPress={importAll}>
+ <Text style={s.ctaTxt}>{items.length ? 'Re-sync contacts' : 'Import my contacts'}</Text>
+ </TouchableOpacity>
+ {summary && <Text style={s.summary}>Imported {summary.imported} Β· {summary.matched} already here π</Text>}
+ </View>
+ {loading ? <ActivityIndicator style={{ marginTop: 30 }} /> : (
+ <FlatList data={items} keyExtractor={x => String(x.id)} contentContainerStyle={{ padding: 12 }}
+ ListEmptyComponent={<Text style={s.empty}>No contacts imported yet.</Text>}
+ renderItem={({ item }) => (
+ <View style={s.row}>
+ <View style={{ flex: 1 }}>
+ <Text style={s.name}>{item.display_name || item.phone_e164}</Text>
+ <Text style={s.phone}>{item.phone_e164}</Text>
+ </View>
+ {item.on_app
+ ? <Text style={s.on}>β On app</Text>
+ : <TouchableOpacity style={[s.invite, item.invited_at && s.invited]} onPress={() => invite(item)}>
+ <Text style={item.invited_at ? s.invitedTxt : s.inviteTxt}>{item.invited_at ? 'Invited' : 'Invite'}</Text>
+ </TouchableOpacity>}
+ </View>
+ )} />
+ )}
+ </View>
+ );
+}
+const s = StyleSheet.create({
+ wrap: { flex: 1, backgroundColor: '#f6f7f5' },
+ head: { padding: 16, backgroundColor: '#fff', borderBottomWidth: 1, borderColor: '#e8ebe6' },
+ h: { fontSize: 22, fontWeight: '800', color: '#1a2b22' },
+ sub: { color: '#6b756e', marginTop: 4, lineHeight: 19 },
+ cta: { backgroundColor: '#0a7d55', height: 48, borderRadius: 12, alignItems: 'center', justifyContent: 'center', marginTop: 14 },
+ ctaTxt: { color: '#fff', fontWeight: '700', fontSize: 16 },
+ summary: { marginTop: 10, color: '#0a7d55', fontWeight: '600' },
+ row: { flexDirection: 'row', alignItems: 'center', backgroundColor: '#fff', borderRadius: 10, padding: 12, marginBottom: 8, borderWidth: 1, borderColor: '#e8ebe6' },
+ name: { fontWeight: '600', color: '#1a2b22' }, phone: { color: '#6b756e', fontSize: 13, marginTop: 2 },
+ on: { color: '#0a7d55', fontWeight: '700' },
+ invite: { backgroundColor: '#0a7d55', paddingHorizontal: 16, paddingVertical: 8, borderRadius: 8 },
+ invited: { backgroundColor: '#eef5f1' },
+ inviteTxt: { color: '#fff', fontWeight: '700' }, invitedTxt: { color: '#0a7d55', fontWeight: '600' },
+ empty: { textAlign: 'center', color: '#6b756e', marginTop: 40 },
+});
diff --git a/app/index.tsx b/app/index.tsx
index 1328888..09a4323 100644
--- a/app/index.tsx
+++ b/app/index.tsx
@@ -31,6 +31,9 @@ export default function Directory() {
<View style={s.searchRow}>
<TextInput style={s.search} placeholder="Search stays, tours, servicesβ¦" value={q}
onChangeText={setQ} onSubmitEditing={load} returnKeyType="search" />
+ <TouchableOpacity style={s.iconbtn} onPress={() => router.push(isAuthed() ? '/contacts' : '/login')}>
+ <Text style={{ fontSize: 18 }}>π₯</Text>
+ </TouchableOpacity>
<TouchableOpacity style={s.mybtn} onPress={() => router.push(isAuthed() ? '/bookings' : '/login')}>
<Text style={{ color: '#fff' }}>{isAuthed() ? 'Trips' : 'Sign in'}</Text>
</TouchableOpacity>
@@ -63,6 +66,7 @@ const s = StyleSheet.create({
wrap: { flex: 1, backgroundColor: '#f6f7f5' },
searchRow: { flexDirection: 'row', padding: 12, gap: 8, alignItems: 'center' },
search: { flex: 1, backgroundColor: '#fff', borderRadius: 10, paddingHorizontal: 14, height: 44, borderWidth: 1, borderColor: '#e2e5e0' },
+ iconbtn: { backgroundColor: '#fff', width: 44, height: 44, borderRadius: 10, alignItems: 'center', justifyContent: 'center', borderWidth: 1, borderColor: '#e2e5e0' },
mybtn: { backgroundColor: '#0a7d55', paddingHorizontal: 14, height: 44, borderRadius: 10, justifyContent: 'center' },
chips: { flexGrow: 0, paddingHorizontal: 8 },
chip: { paddingHorizontal: 14, paddingVertical: 8, marginHorizontal: 4, borderRadius: 20, borderWidth: 1, borderColor: '#0a7d55', backgroundColor: '#fff' },
diff --git a/package.json b/package.json
index d15b490..d9b05dd 100644
--- a/package.json
+++ b/package.json
@@ -13,6 +13,7 @@
"expo-apple-authentication": "~7.1.0",
"expo-router": "~4.0.0",
"expo-constants": "~17.0.0",
+ "expo-contacts": "~14.0.0",
"expo-linking": "~7.0.0",
"expo-web-browser": "~14.0.0",
"expo-secure-store": "~14.0.0",
diff --git a/src/api.ts b/src/api.ts
index f98a1cc..93225f6 100644
--- a/src/api.ts
+++ b/src/api.ts
@@ -43,6 +43,10 @@ export const api = {
hostApply: (b: any) => req('/host/apply', { method: 'POST', body: JSON.stringify(b) }),
hostListing: (b: any) => req('/host/listings', { method: 'POST', body: JSON.stringify(b) }),
addPayoutMethod: (b: any) => req('/host/payout-methods', { method: 'POST', body: JSON.stringify(b) }),
+ // contacts
+ importContacts: (contacts: any[]) => req('/contacts/import', { method: 'POST', body: JSON.stringify({ contacts }) }),
+ contacts: () => req('/contacts'),
+ inviteContact: (id: number) => req(`/contacts/${id}/invite`, { method: 'POST', body: '{}' }),
plaidLinkToken: () => req('/host/plaid/link-token', { method: 'POST', body: '{}' }),
plaidExchange: (public_token: string) => req('/host/plaid/exchange', { method: 'POST', body: JSON.stringify({ public_token }) }),
};
β 2d0b15d costa-rica-app: Sign in with Apple β expo-apple-authenticati
Β·
back to Costa Rica App
Β·
auto-data-snapshot: 2026-08-07T11:14:36 (3 data files) β app 7d6bbd9 β