[object Object]

← 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

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 β†’