← 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
A .gitignoreA README.mdA app.jsonA app/_layout.tsxA app/book/[slug].tsxA app/bookings.tsxA app/host.tsxA app/index.tsxA app/listing/[slug].tsxA app/login.tsxA eas.jsonA package.jsonA src/api.tsA tsconfig.json
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 →