[object Object]

← back to Costa Rica App

costa-rica-app: Expo/EAS native scaffold — Router nav, JWT auth, directory browse, listing, booking+payment (card/SINPE via Tilopay 3DS), my-trips, host onboarding (SINPE/Plaid); eas.json wired to account ASC key (public IDs only, .p8 gitignored)

e816038d6f929802148337f9586d49d3db8ae159 · 2026-08-07 09:56:15 -0700 · Steve Abrams

Files touched

Diff

commit e816038d6f929802148337f9586d49d3db8ae159
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Fri Aug 7 09:56:15 2026 -0700

    costa-rica-app: Expo/EAS native scaffold — Router nav, JWT auth, directory browse, listing, booking+payment (card/SINPE via Tilopay 3DS), my-trips, host onboarding (SINPE/Plaid); eas.json wired to account ASC key (public IDs only, .p8 gitignored)
---
 .gitignore             |  11 +++++
 README.md              |  39 ++++++++++++++++++
 app.json               |  28 +++++++++++++
 app/_layout.tsx        |  19 +++++++++
 app/book/[slug].tsx    | 107 +++++++++++++++++++++++++++++++++++++++++++++++++
 app/bookings.tsx       |  38 ++++++++++++++++++
 app/host.tsx           |  95 +++++++++++++++++++++++++++++++++++++++++++
 app/index.tsx          |  77 +++++++++++++++++++++++++++++++++++
 app/listing/[slug].tsx |  45 +++++++++++++++++++++
 app/login.tsx          |  47 ++++++++++++++++++++++
 eas.json               |  19 +++++++++
 package.json           |  27 +++++++++++++
 src/api.ts             |  49 ++++++++++++++++++++++
 tsconfig.json          |   5 +++
 14 files changed, 606 insertions(+)

diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..2a6c838
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,11 @@
+node_modules/
+.expo/
+dist/
+web-build/
+*.log
+.DS_Store
+.env*
+ios/
+android/
+*.p8
+*.mobileprovision
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..86b486c
--- /dev/null
+++ b/README.md
@@ -0,0 +1,39 @@
+# Costa Rica — native app (Expo/EAS)
+
+iOS + Android client for the Costa Rica directory → booking → payment marketplace.
+Backend API: `~/Projects/costa-rica` (Express, `/api/app/*`).
+
+## Stack
+- Expo SDK 52 + Expo Router (file-based nav)
+- `expo-secure-store` — JWT persistence
+- `expo-web-browser` — Tilopay 3-D Secure redirect (`ASWebAuthenticationSession`)
+- Deep-link scheme `crmarketplace://pay/return`
+
+## Screens
+| Route | Purpose |
+|---|---|
+| `app/index.tsx` | Directory browse (search + vertical chips) |
+| `app/listing/[slug].tsx` | Listing detail |
+| `app/book/[slug].tsx` | Booking + **payment** (card / SINPE Móvil) |
+| `app/bookings.tsx` | My trips |
+| `app/login.tsx` | Sign in / register |
+| `app/host.tsx` | Host onboarding (SINPE / Plaid payout) |
+
+## Run (dev)
+```bash
+npm install
+npx expo start          # scan QR with Expo Go, or run a dev build
+```
+Point at a local backend by editing `extra.apiBase` in `app.json`.
+
+## Build & submit — GATED (Steve only)
+`eas.json` references the **account-level ASC key** (`72Y2TZT54R`) by path — never copied.
+```bash
+eas login && eas init          # sets extra.eas.projectId
+eas build -p ios --profile production
+eas submit -p ios --latest     # uses the account ASC key
+```
+Bundle id `com.abrams.costarica` · Apple team `3VAV3KMNZK`.
+
+> Live build/submit, DNS, and live payment/WhatsApp keys are Steve-gated — see
+> `~/.claude/yolo-queue/pending-approval/costa-rica-golive.md`.
diff --git a/app.json b/app.json
new file mode 100644
index 0000000..a43cfcc
--- /dev/null
+++ b/app.json
@@ -0,0 +1,28 @@
+{
+  "expo": {
+    "name": "Costa Rica",
+    "slug": "costa-rica-app",
+    "version": "1.0.0",
+    "orientation": "portrait",
+    "scheme": "crmarketplace",
+    "userInterfaceStyle": "automatic",
+    "newArchEnabled": true,
+    "ios": {
+      "bundleIdentifier": "com.abrams.costarica",
+      "supportsTablet": true,
+      "infoPlist": {
+        "ITSAppUsesNonExemptEncryption": false,
+        "NSLocationWhenInUseUsageDescription": "Show nearby stays, tours, and services on the map."
+      }
+    },
+    "android": {
+      "package": "com.abrams.costarica",
+      "permissions": ["ACCESS_FINE_LOCATION"]
+    },
+    "plugins": ["expo-router", "expo-secure-store"],
+    "extra": {
+      "apiBase": "https://costarica.agentabrams.com",
+      "eas": { "projectId": "TO_BE_SET_BY_eas_init" }
+    }
+  }
+}
diff --git a/app/_layout.tsx b/app/_layout.tsx
new file mode 100644
index 0000000..3f5be03
--- /dev/null
+++ b/app/_layout.tsx
@@ -0,0 +1,19 @@
+import { Stack } from 'expo-router';
+import { useEffect, useState } from 'react';
+import { loadToken } from '../src/api';
+
+export default function RootLayout() {
+  const [ready, setReady] = useState(false);
+  useEffect(() => { loadToken().finally(() => setReady(true)); }, []);
+  if (!ready) return null;
+  return (
+    <Stack screenOptions={{ headerStyle: { backgroundColor: '#0a7d55' }, headerTintColor: '#fff' }}>
+      <Stack.Screen name="index" options={{ title: 'Costa Rica' }} />
+      <Stack.Screen name="listing/[slug]" options={{ title: 'Listing' }} />
+      <Stack.Screen name="book/[slug]" options={{ title: 'Book' }} />
+      <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>
+  );
+}
diff --git a/app/book/[slug].tsx b/app/book/[slug].tsx
new file mode 100644
index 0000000..1b92376
--- /dev/null
+++ b/app/book/[slug].tsx
@@ -0,0 +1,107 @@
+import { useEffect, useState } from 'react';
+import { View, Text, TextInput, TouchableOpacity, StyleSheet, Alert, ActivityIndicator } from 'react-native';
+import { useLocalSearchParams, router } from 'expo-router';
+import * as WebBrowser from 'expo-web-browser';
+import { api, money, isAuthed } from '../../src/api';
+
+export default function BookScreen() {
+  const { slug } = useLocalSearchParams<{ slug: string }>();
+  const [l, setL] = useState<any>(null);
+  const [checkIn, setCheckIn] = useState('2026-09-10');
+  const [checkOut, setCheckOut] = useState('2026-09-13');
+  const [guests, setGuests] = useState('2');
+  const [booking, setBooking] = useState<any>(null);
+  const [busy, setBusy] = useState(false);
+
+  useEffect(() => { api.listing(slug!).then(r => setL(r.listing)).catch(() => {}); }, [slug]);
+  if (!l) return <ActivityIndicator style={{ marginTop: 40 }} />;
+
+  async function quote() {
+    if (!isAuthed()) { router.push('/login'); return; }
+    setBusy(true);
+    try {
+      const r = await api.createBooking({ place_slug: slug, check_in: checkIn, check_out: checkOut, guests: +guests });
+      setBooking({ ...r.booking, split: r.split });
+    } catch (e: any) { Alert.alert('Could not create booking', e.message); } finally { setBusy(false); }
+  }
+
+  async function pay(method: 'card' | 'sinpe') {
+    setBusy(true);
+    try {
+      const r = await api.pay(booking.code, method);
+      if (r.client_action?.type === 'redirect') {
+        // Tilopay 3-D Secure / hosted checkout — return via the app scheme.
+        await WebBrowser.openAuthSessionAsync(r.client_action.url, 'crmarketplace://pay/return');
+      } else if (r.client_action?.type === 'sinpe_instructions') {
+        Alert.alert('SINPE Móvil', `${r.client_action.note}\n\nSINPE: ${r.client_action.sinpe_phone}`);
+      }
+      // Poll for confirmation
+      for (let i = 0; i < 10; i++) {
+        const p = await api.payment(String(r.payment_id));
+        if (p.payment.status === 'succeeded') {
+          Alert.alert('Confirmed! 🎉', `Booking ${booking.code} is confirmed.`);
+          router.replace('/bookings'); return;
+        }
+        if (p.payment.status === 'failed') { Alert.alert('Payment failed'); return; }
+        await new Promise(r => setTimeout(r, 1500));
+      }
+      router.replace('/bookings');
+    } catch (e: any) { Alert.alert('Payment error', e.message); } finally { setBusy(false); }
+  }
+
+  return (
+    <View style={s.wrap}>
+      <Text style={s.name}>{l.name}</Text>
+      {!booking ? (
+        <>
+          <Field label="Check-in (YYYY-MM-DD)" value={checkIn} onChange={setCheckIn} />
+          <Field label="Check-out (YYYY-MM-DD)" value={checkOut} onChange={setCheckOut} />
+          <Field label="Guests" value={guests} onChange={setGuests} keyboard="number-pad" />
+          <TouchableOpacity style={s.cta} onPress={quote} disabled={busy}>
+            <Text style={s.ctaTxt}>{busy ? '…' : 'Review price'}</Text>
+          </TouchableOpacity>
+        </>
+      ) : (
+        <>
+          <View style={s.box}>
+            <Row k="Subtotal" v={money(booking.split.subtotal, booking.currency)} />
+            {booking.split.cleaningFee ? <Row k="Cleaning" v={money(booking.split.cleaningFee, booking.currency)} /> : null}
+            <Row k="Service fee" v={money(booking.split.platformFee, booking.currency)} />
+            <View style={s.hr} />
+            <Row k="Total" v={money(booking.split.total, booking.currency)} bold />
+          </View>
+          <Text style={s.pick}>Pay with</Text>
+          <TouchableOpacity style={s.cta} onPress={() => pay('card')} disabled={busy}>
+            <Text style={s.ctaTxt}>{busy ? '…' : '💳 Card (USD/CRC)'}</Text>
+          </TouchableOpacity>
+          <TouchableOpacity style={[s.cta, s.ctaAlt]} onPress={() => pay('sinpe')} disabled={busy}>
+            <Text style={[s.ctaTxt, { color: '#0a7d55' }]}>📱 SINPE Móvil</Text>
+          </TouchableOpacity>
+        </>
+      )}
+    </View>
+  );
+}
+const Field = ({ label, value, onChange, keyboard }: any) => (
+  <View style={{ marginTop: 12 }}>
+    <Text style={s.lbl}>{label}</Text>
+    <TextInput style={s.inp} value={value} onChangeText={onChange} keyboardType={keyboard || 'default'} autoCapitalize="none" />
+  </View>
+);
+const Row = ({ k, v, bold }: any) => (
+  <View style={s.row}><Text style={[s.rk, bold && s.b]}>{k}</Text><Text style={[s.rv, bold && s.b]}>{v}</Text></View>
+);
+const s = StyleSheet.create({
+  wrap: { flex: 1, padding: 16, backgroundColor: '#f6f7f5' },
+  name: { fontSize: 20, fontWeight: '800', color: '#1a2b22' },
+  lbl: { color: '#6b756e', marginBottom: 4, fontSize: 13 },
+  inp: { backgroundColor: '#fff', borderRadius: 10, height: 46, paddingHorizontal: 14, borderWidth: 1, borderColor: '#e2e5e0' },
+  cta: { backgroundColor: '#0a7d55', height: 52, borderRadius: 12, alignItems: 'center', justifyContent: 'center', marginTop: 14 },
+  ctaAlt: { backgroundColor: '#fff', borderWidth: 1.5, borderColor: '#0a7d55' },
+  ctaTxt: { color: '#fff', fontSize: 16, fontWeight: '700' },
+  box: { backgroundColor: '#fff', borderRadius: 12, padding: 16, marginTop: 16, borderWidth: 1, borderColor: '#e8ebe6' },
+  row: { flexDirection: 'row', justifyContent: 'space-between', paddingVertical: 4 },
+  rk: { color: '#6b756e' }, rv: { color: '#1a2b22' }, b: { fontWeight: '800', fontSize: 16 },
+  hr: { height: 1, backgroundColor: '#eee', marginVertical: 8 },
+  pick: { marginTop: 20, fontWeight: '700', color: '#1a2b22' },
+});
diff --git a/app/bookings.tsx b/app/bookings.tsx
new file mode 100644
index 0000000..accb0e2
--- /dev/null
+++ b/app/bookings.tsx
@@ -0,0 +1,38 @@
+import { useEffect, useState } from 'react';
+import { View, Text, FlatList, StyleSheet, TouchableOpacity } from 'react-native';
+import { router } from 'expo-router';
+import { api, money, isAuthed } from '../src/api';
+
+const COLOR: Record<string, string> = {
+  confirmed: '#0a7d55', pending: '#b8860b', cancelled: '#a33', completed: '#356', refunded: '#777',
+};
+export default function Bookings() {
+  const [items, setItems] = useState<any[]>([]);
+  useEffect(() => {
+    if (!isAuthed()) { router.replace('/login'); return; }
+    api.myBookings().then(r => setItems(r.bookings || [])).catch(() => {});
+  }, []);
+  return (
+    <FlatList style={{ backgroundColor: '#f6f7f5' }} data={items} keyExtractor={x => x.code}
+      contentContainerStyle={{ padding: 12 }}
+      ListEmptyComponent={<Text style={s.empty}>No trips yet. Explore and book your first stay.</Text>}
+      renderItem={({ item }) => (
+        <TouchableOpacity style={s.card} onPress={() => router.push(`/listing/${item.place_slug}`)}>
+          <View style={{ flexDirection: 'row', justifyContent: 'space-between' }}>
+            <Text style={s.name}>{item.place_name}</Text>
+            <Text style={[s.badge, { color: COLOR[item.status] || '#555' }]}>{item.status}</Text>
+          </View>
+          <Text style={s.meta}>{item.code} · {item.check_in || item.slot_start} → {item.check_out || ''}</Text>
+          <Text style={s.total}>{money(item.total, item.currency)}</Text>
+        </TouchableOpacity>
+      )} />
+  );
+}
+const s = StyleSheet.create({
+  card: { backgroundColor: '#fff', borderRadius: 12, padding: 14, marginBottom: 10, borderWidth: 1, borderColor: '#e8ebe6' },
+  name: { fontSize: 16, fontWeight: '700', color: '#1a2b22', flex: 1 },
+  badge: { fontWeight: '700', textTransform: 'capitalize' },
+  meta: { color: '#6b756e', marginTop: 4, fontSize: 13 },
+  total: { marginTop: 8, fontWeight: '700', color: '#0a7d55' },
+  empty: { textAlign: 'center', color: '#6b756e', marginTop: 60 },
+});
diff --git a/app/host.tsx b/app/host.tsx
new file mode 100644
index 0000000..7e9caa9
--- /dev/null
+++ b/app/host.tsx
@@ -0,0 +1,95 @@
+import { useState } from 'react';
+import { View, Text, TextInput, TouchableOpacity, StyleSheet, Alert, ScrollView } from 'react-native';
+import { api } from '../src/api';
+
+// Host onboarding: apply -> add a payout method (SINPE for Tico hosts, Plaid for
+// foreign hosts) -> list a place for booking.
+export default function Host() {
+  const [legal, setLegal] = useState('');
+  const [cedula, setCedula] = useState('');
+  const [country, setCountry] = useState<'CR' | 'US'>('CR');
+  const [sinpe, setSinpe] = useState('');
+  const [placeSlug, setPlaceSlug] = useState('');
+  const [price, setPrice] = useState('120');
+  const [busy, setBusy] = useState(false);
+  const [step, setStep] = useState(1);
+
+  async function apply() {
+    setBusy(true);
+    try { await api.hostApply({ legal_name: legal, cedula, country }); setStep(2); }
+    catch (e: any) { Alert.alert('Error', e.message); } finally { setBusy(false); }
+  }
+  async function addPayout() {
+    setBusy(true);
+    try {
+      if (country === 'CR') {
+        await api.addPayoutMethod({ kind: 'sinpe_movil', sinpe_phone: sinpe, currency: 'CRC', is_default: true });
+      } else {
+        const t = await api.plaidLinkToken(); // open Plaid Link with t.link_token in a real build
+        Alert.alert('Plaid', `Link token issued (${t.env}). In production this opens Plaid Link; sandbox auto-verifies.`);
+        await api.plaidExchange('public-sandbox-token');
+      }
+      setStep(3);
+    } catch (e: any) { Alert.alert('Error', e.message); } finally { setBusy(false); }
+  }
+  async function listPlace() {
+    setBusy(true);
+    try {
+      await api.hostListing({ place_slug: placeSlug, booking_type: 'nightly', currency: 'USD', base_price: Math.round(+price * 100), max_guests: 4 });
+      Alert.alert('Listed! 🎉', 'Your place is now bookable.');
+    } catch (e: any) { Alert.alert('Error', e.message); } finally { setBusy(false); }
+  }
+
+  return (
+    <ScrollView contentContainerStyle={s.wrap}>
+      <Text style={s.h}>Become a Host</Text>
+      <Text style={s.step}>Step {step} of 3</Text>
+      {step === 1 && (<>
+        <Field label="Legal / business name" value={legal} onChange={setLegal} />
+        <Field label="Cédula (juridica/fisica)" value={cedula} onChange={setCedula} />
+        <View style={s.seg}>
+          {(['CR', 'US'] as const).map(c => (
+            <TouchableOpacity key={c} style={[s.segBtn, country === c && s.segOn]} onPress={() => setCountry(c)}>
+              <Text style={country === c ? { color: '#fff' } : { color: '#0a7d55' }}>{c === 'CR' ? '🇨🇷 Costa Rica' : '🌎 Foreign'}</Text>
+            </TouchableOpacity>
+          ))}
+        </View>
+        <Btn label="Continue" onPress={apply} busy={busy} />
+      </>)}
+      {step === 2 && (<>
+        <Text style={s.info}>{country === 'CR' ? 'Payouts via SINPE Móvil (colones).' : 'Payouts via Plaid-linked bank (ACH).'}</Text>
+        {country === 'CR'
+          ? <Field label="SINPE Móvil number (+506…)" value={sinpe} onChange={setSinpe} />
+          : <Text style={s.info}>Tap continue to link your bank via Plaid.</Text>}
+        <Btn label={country === 'CR' ? 'Save SINPE' : 'Link bank (Plaid)'} onPress={addPayout} busy={busy} />
+      </>)}
+      {step === 3 && (<>
+        <Field label="Place slug to list (from directory)" value={placeSlug} onChange={setPlaceSlug} />
+        <Field label="Price per night (USD)" value={price} onChange={setPrice} keyboard="number-pad" />
+        <Btn label="List my place" onPress={listPlace} busy={busy} />
+      </>)}
+    </ScrollView>
+  );
+}
+const Field = ({ label, value, onChange, keyboard }: any) => (
+  <View style={{ marginTop: 12 }}>
+    <Text style={s.lbl}>{label}</Text>
+    <TextInput style={s.i} value={value} onChangeText={onChange} keyboardType={keyboard || 'default'} autoCapitalize="none" />
+  </View>
+);
+const Btn = ({ label, onPress, busy }: any) => (
+  <TouchableOpacity style={s.cta} onPress={onPress} disabled={busy}><Text style={s.ctaTxt}>{busy ? '…' : label}</Text></TouchableOpacity>
+);
+const s = StyleSheet.create({
+  wrap: { padding: 20, backgroundColor: '#f6f7f5', flexGrow: 1 },
+  h: { fontSize: 24, fontWeight: '800', color: '#1a2b22' },
+  step: { color: '#6b756e', marginTop: 4, marginBottom: 8 },
+  info: { color: '#33413a', marginTop: 12, lineHeight: 20 },
+  lbl: { color: '#6b756e', marginBottom: 4, fontSize: 13 },
+  i: { backgroundColor: '#fff', borderRadius: 10, height: 46, paddingHorizontal: 14, borderWidth: 1, borderColor: '#e2e5e0' },
+  seg: { flexDirection: 'row', gap: 8, marginTop: 14 },
+  segBtn: { flex: 1, height: 46, borderRadius: 10, borderWidth: 1.5, borderColor: '#0a7d55', alignItems: 'center', justifyContent: 'center', backgroundColor: '#fff' },
+  segOn: { backgroundColor: '#0a7d55' },
+  cta: { backgroundColor: '#0a7d55', height: 52, borderRadius: 12, alignItems: 'center', justifyContent: 'center', marginTop: 20 },
+  ctaTxt: { color: '#fff', fontSize: 16, fontWeight: '700' },
+});
diff --git a/app/index.tsx b/app/index.tsx
new file mode 100644
index 0000000..1328888
--- /dev/null
+++ b/app/index.tsx
@@ -0,0 +1,77 @@
+import { useEffect, useState } from 'react';
+import { View, Text, FlatList, TextInput, TouchableOpacity, StyleSheet, ActivityIndicator } from 'react-native';
+import { Link, router } from 'expo-router';
+import { api, money, isAuthed } from '../src/api';
+
+const VERTICALS = [
+  { k: '', label: 'All' },
+  { k: 'tourism_hotel', label: 'Stays' },
+  { k: 'tourism_tour', label: 'Tours' },
+  { k: 'tourism_restaurant', label: 'Eat' },
+  { k: 'service_beauty', label: 'Beauty' },
+];
+
+export default function Directory() {
+  const [items, setItems] = useState<any[]>([]);
+  const [q, setQ] = useState('');
+  const [vert, setVert] = useState('');
+  const [loading, setLoading] = useState(true);
+
+  async function load() {
+    setLoading(true);
+    try {
+      const r = await api.listings({ ...(q && { q }), ...(vert && { vertical: vert }), limit: '40' });
+      setItems(r.listings || []);
+    } catch (e) { setItems([]); } finally { setLoading(false); }
+  }
+  useEffect(() => { load(); }, [vert]);
+
+  return (
+    <View style={s.wrap}>
+      <View style={s.searchRow}>
+        <TextInput style={s.search} placeholder="Search stays, tours, services…" value={q}
+          onChangeText={setQ} onSubmitEditing={load} returnKeyType="search" />
+        <TouchableOpacity style={s.mybtn} onPress={() => router.push(isAuthed() ? '/bookings' : '/login')}>
+          <Text style={{ color: '#fff' }}>{isAuthed() ? 'Trips' : 'Sign in'}</Text>
+        </TouchableOpacity>
+      </View>
+      <FlatList horizontal showsHorizontalScrollIndicator={false} data={VERTICALS} keyExtractor={x => x.k}
+        style={s.chips} renderItem={({ item }) => (
+          <TouchableOpacity style={[s.chip, vert === item.k && s.chipOn]} onPress={() => setVert(item.k)}>
+            <Text style={vert === item.k ? { color: '#fff' } : { color: '#0a7d55' }}>{item.label}</Text>
+          </TouchableOpacity>
+        )} />
+      {loading ? <ActivityIndicator style={{ marginTop: 40 }} /> : (
+        <FlatList data={items} keyExtractor={x => x.slug} contentContainerStyle={{ padding: 12 }}
+          ListEmptyComponent={<Text style={s.empty}>No bookable listings yet in this filter.</Text>}
+          renderItem={({ item }) => (
+            <Link href={`/listing/${item.slug}`} asChild>
+              <TouchableOpacity style={s.card}>
+                <Text style={s.name}>{item.name}</Text>
+                <Text style={s.meta}>{item.region} · {String(item.vertical).replace(/_/g, ' ')}</Text>
+                <Text style={s.price}>{money(item.base_price, item.currency)}<Text style={s.per}> / {item.booking_type === 'nightly' ? 'night' : 'person'}</Text></Text>
+                {item.rating ? <Text style={s.rating}>★ {item.rating}</Text> : null}
+              </TouchableOpacity>
+            </Link>
+          )} />
+      )}
+    </View>
+  );
+}
+
+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' },
+  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' },
+  chipOn: { backgroundColor: '#0a7d55' },
+  card: { backgroundColor: '#fff', borderRadius: 12, padding: 14, marginBottom: 10, borderWidth: 1, borderColor: '#e8ebe6' },
+  name: { fontSize: 16, fontWeight: '700', color: '#1a2b22' },
+  meta: { color: '#6b756e', marginTop: 2, fontSize: 13 },
+  price: { marginTop: 8, fontSize: 15, fontWeight: '700', color: '#0a7d55' },
+  per: { fontWeight: '400', color: '#6b756e' },
+  rating: { position: 'absolute', top: 14, right: 14, color: '#b8860b' },
+  empty: { textAlign: 'center', color: '#6b756e', marginTop: 40 },
+});
diff --git a/app/listing/[slug].tsx b/app/listing/[slug].tsx
new file mode 100644
index 0000000..ecd17b4
--- /dev/null
+++ b/app/listing/[slug].tsx
@@ -0,0 +1,45 @@
+import { useEffect, useState } from 'react';
+import { View, Text, ScrollView, TouchableOpacity, StyleSheet, ActivityIndicator } from 'react-native';
+import { useLocalSearchParams, router } from 'expo-router';
+import { api, money } from '../../src/api';
+
+export default function ListingDetail() {
+  const { slug } = useLocalSearchParams<{ slug: string }>();
+  const [l, setL] = useState<any>(null);
+  useEffect(() => { api.listing(slug!).then(r => setL(r.listing)).catch(() => setL(false)); }, [slug]);
+  if (l === null) return <ActivityIndicator style={{ marginTop: 40 }} />;
+  if (l === false) return <Text style={s.err}>Not available.</Text>;
+  return (
+    <View style={{ flex: 1 }}>
+      <ScrollView contentContainerStyle={{ padding: 16 }}>
+        <Text style={s.name}>{l.name}</Text>
+        <Text style={s.region}>{l.region} · {String(l.vertical).replace(/_/g, ' ')}</Text>
+        {l.rating ? <Text style={s.rating}>★ {l.rating}</Text> : null}
+        {l.description ? <Text style={s.desc}>{l.description}</Text> : null}
+        <View style={s.box}>
+          <Text style={s.price}>{money(l.base_price, l.currency)} <Text style={s.per}>/ {l.booking_type === 'nightly' ? 'night' : 'person'}</Text></Text>
+          {l.cleaning_fee ? <Text style={s.fee}>+ {money(l.cleaning_fee, l.currency)} cleaning</Text> : null}
+          <Text style={s.small}>Up to {l.max_guests} guests · {l.instant_book ? 'Instant book' : 'Request to book'} · {l.cancellation}</Text>
+        </View>
+        {l.address ? <Text style={s.small}>📍 {l.address}</Text> : null}
+      </ScrollView>
+      <TouchableOpacity style={s.cta} onPress={() => router.push(`/book/${slug}`)}>
+        <Text style={s.ctaTxt}>Book now</Text>
+      </TouchableOpacity>
+    </View>
+  );
+}
+const s = StyleSheet.create({
+  name: { fontSize: 22, fontWeight: '800', color: '#1a2b22' },
+  region: { color: '#6b756e', marginTop: 4 },
+  rating: { color: '#b8860b', marginTop: 6 },
+  desc: { marginTop: 12, lineHeight: 21, color: '#33413a' },
+  box: { marginTop: 16, backgroundColor: '#eef5f1', borderRadius: 12, padding: 16 },
+  price: { fontSize: 20, fontWeight: '800', color: '#0a7d55' },
+  per: { fontWeight: '400', color: '#6b756e', fontSize: 15 },
+  fee: { color: '#33413a', marginTop: 4 },
+  small: { color: '#6b756e', marginTop: 8 },
+  cta: { backgroundColor: '#0a7d55', margin: 16, height: 52, borderRadius: 12, alignItems: 'center', justifyContent: 'center' },
+  ctaTxt: { color: '#fff', fontSize: 17, fontWeight: '700' },
+  err: { textAlign: 'center', marginTop: 40, color: '#a33' },
+});
diff --git a/app/login.tsx b/app/login.tsx
new file mode 100644
index 0000000..b00b8b5
--- /dev/null
+++ b/app/login.tsx
@@ -0,0 +1,47 @@
+import { useState } from 'react';
+import { View, Text, TextInput, TouchableOpacity, StyleSheet, Alert } from 'react-native';
+import { router } from 'expo-router';
+import { api, setToken } from '../src/api';
+
+export default function Login() {
+  const [mode, setMode] = useState<'login' | 'register'>('login');
+  const [email, setEmail] = useState('');
+  const [password, setPassword] = useState('');
+  const [name, setName] = useState('');
+  const [phone, setPhone] = useState('');
+  const [busy, setBusy] = useState(false);
+
+  async function submit() {
+    setBusy(true);
+    try {
+      const r = mode === 'login'
+        ? await api.login(email, password)
+        : await api.register({ email, password, full_name: name, phone });
+      await setToken(r.token);
+      router.back();
+    } catch (e: any) { Alert.alert('Error', e.message); } finally { setBusy(false); }
+  }
+  return (
+    <View style={s.wrap}>
+      <Text style={s.h}>{mode === 'login' ? 'Welcome back' : 'Create account'}</Text>
+      {mode === 'register' && <TextInput style={s.i} placeholder="Full name" value={name} onChangeText={setName} />}
+      <TextInput style={s.i} placeholder="Email" autoCapitalize="none" keyboardType="email-address" value={email} onChangeText={setEmail} />
+      {mode === 'register' && <TextInput style={s.i} placeholder="Phone (+506…)" keyboardType="phone-pad" value={phone} onChangeText={setPhone} />}
+      <TextInput style={s.i} placeholder="Password" secureTextEntry value={password} onChangeText={setPassword} />
+      <TouchableOpacity style={s.cta} onPress={submit} disabled={busy}>
+        <Text style={s.ctaTxt}>{busy ? '…' : mode === 'login' ? 'Sign in' : 'Sign up'}</Text>
+      </TouchableOpacity>
+      <TouchableOpacity onPress={() => setMode(mode === 'login' ? 'register' : 'login')}>
+        <Text style={s.switch}>{mode === 'login' ? 'New here? Create an account' : 'Have an account? Sign in'}</Text>
+      </TouchableOpacity>
+    </View>
+  );
+}
+const s = StyleSheet.create({
+  wrap: { flex: 1, padding: 20, backgroundColor: '#f6f7f5' },
+  h: { fontSize: 24, fontWeight: '800', color: '#1a2b22', marginBottom: 16 },
+  i: { backgroundColor: '#fff', borderRadius: 10, height: 48, paddingHorizontal: 14, marginBottom: 12, borderWidth: 1, borderColor: '#e2e5e0' },
+  cta: { backgroundColor: '#0a7d55', height: 52, borderRadius: 12, alignItems: 'center', justifyContent: 'center', marginTop: 6 },
+  ctaTxt: { color: '#fff', fontSize: 16, fontWeight: '700' },
+  switch: { color: '#0a7d55', textAlign: 'center', marginTop: 18 },
+});
diff --git a/eas.json b/eas.json
new file mode 100644
index 0000000..4008d58
--- /dev/null
+++ b/eas.json
@@ -0,0 +1,19 @@
+{
+  "cli": { "version": ">= 12.0.0", "appVersionSource": "remote" },
+  "build": {
+    "development": { "developmentClient": true, "distribution": "internal" },
+    "preview": { "distribution": "internal", "ios": { "simulator": true } },
+    "production": { "autoIncrement": true }
+  },
+  "submit": {
+    "production": {
+      "ios": {
+        "//": "Account-level ASC key (72Y2TZT54R) submits ALL Steve's iOS apps. Referenced by path, NEVER copied (classifier rule).",
+        "ascApiKeyPath": "../nineoh-guide/.credentials/asc-api-key.p8",
+        "ascApiKeyId": "72Y2TZT54R",
+        "ascApiKeyIssuerId": "cfbd63ed-301b-465c-aad7-49e94420ad70",
+        "appleTeamId": "3VAV3KMNZK"
+      }
+    }
+  }
+}
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..9ac980b
--- /dev/null
+++ b/package.json
@@ -0,0 +1,27 @@
+{
+  "name": "costa-rica-app",
+  "version": "0.1.0",
+  "private": true,
+  "main": "expo-router/entry",
+  "scripts": {
+    "start": "expo start",
+    "ios": "expo start --ios",
+    "android": "expo start --android"
+  },
+  "dependencies": {
+    "expo": "^52.0.0",
+    "expo-router": "~4.0.0",
+    "expo-constants": "~17.0.0",
+    "expo-linking": "~7.0.0",
+    "expo-web-browser": "~14.0.0",
+    "expo-secure-store": "~14.0.0",
+    "react": "18.3.1",
+    "react-native": "0.76.5",
+    "react-native-safe-area-context": "4.12.0",
+    "react-native-screens": "~4.1.0"
+  },
+  "devDependencies": {
+    "@types/react": "~18.3.12",
+    "typescript": "^5.3.3"
+  }
+}
diff --git a/src/api.ts b/src/api.ts
new file mode 100644
index 0000000..3aaaa50
--- /dev/null
+++ b/src/api.ts
@@ -0,0 +1,49 @@
+// API client for the costa-rica backend. Token persisted in SecureStore.
+import Constants from 'expo-constants';
+import * as SecureStore from 'expo-secure-store';
+
+const BASE = (Constants.expoConfig?.extra as any)?.apiBase || 'https://costarica.agentabrams.com';
+let _token: string | null = null;
+
+export async function loadToken() {
+  _token = await SecureStore.getItemAsync('cr_token');
+  return _token;
+}
+export async function setToken(t: string | null) {
+  _token = t;
+  if (t) await SecureStore.setItemAsync('cr_token', t);
+  else await SecureStore.deleteItemAsync('cr_token');
+}
+export function isAuthed() { return !!_token; }
+
+async function req(path: string, opts: RequestInit = {}) {
+  const headers: any = { 'Content-Type': 'application/json', ...(opts.headers || {}) };
+  if (_token) headers.Authorization = `Bearer ${_token}`;
+  const res = await fetch(`${BASE}/api/app${path}`, { ...opts, headers });
+  const j = await res.json().catch(() => ({}));
+  if (!res.ok || j.ok === false) throw new Error(j.error || `HTTP ${res.status}`);
+  return j;
+}
+
+export const api = {
+  register: (b: any) => req('/auth/register', { method: 'POST', body: JSON.stringify(b) }),
+  login: (email: string, password: string) => req('/auth/login', { method: 'POST', body: JSON.stringify({ email, password }) }),
+  me: () => req('/me'),
+  listings: (q: Record<string, string> = {}) => req('/listings?' + new URLSearchParams(q).toString()),
+  listing: (slug: string) => req(`/listings/${slug}`),
+  availability: (slug: string, from?: string, to?: string) =>
+    req(`/listings/${slug}/availability?` + new URLSearchParams({ ...(from && { from }), ...(to && { to }) } as any)),
+  createBooking: (b: any) => req('/bookings', { method: 'POST', body: JSON.stringify(b) }),
+  myBookings: () => req('/bookings'),
+  booking: (code: string) => req(`/bookings/${code}`),
+  pay: (code: string, method: string) => req(`/bookings/${code}/pay`, { method: 'POST', body: JSON.stringify({ method }) }),
+  payment: (id: string) => req(`/payments/${id}`),
+  // host
+  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) }),
+  plaidLinkToken: () => req('/host/plaid/link-token', { method: 'POST', body: '{}' }),
+  plaidExchange: (public_token: string) => req('/host/plaid/exchange', { method: 'POST', body: JSON.stringify({ public_token }) }),
+};
+
+export const money = (minor: number, cur: string) => `${cur} ${(minor / 100).toFixed(2)}`;
diff --git a/tsconfig.json b/tsconfig.json
new file mode 100644
index 0000000..8555b0d
--- /dev/null
+++ b/tsconfig.json
@@ -0,0 +1,5 @@
+{
+  "extends": "expo/tsconfig.base",
+  "compilerOptions": { "strict": true, "paths": { "@/*": ["./src/*"] } },
+  "include": ["**/*.ts", "**/*.tsx", ".expo/types/**/*.ts", "expo-env.d.ts"]
+}

(oldest)  ·  back to Costa Rica App  ·  costa-rica-app: Sign in with Apple — expo-apple-authenticati 2d0b15d →