← back to Homesonspec
apps/mobile/app/(tabs)/saved.tsx
341 lines
/**
* Tab 2 — Saved Homes (NATIVE — App Store 4.2 differentiator)
*
* Fully native favorites list backed by AsyncStorage.
* Users save homes from the Browse tab; this list persists across launches.
* Tapping a saved home opens the full detail page inside a modal WebView.
*
* This is native-only value: the website has no persistent saved-homes
* feature for anonymous users. The app provides it without requiring login.
*/
import { useCallback, useState } from 'react';
import {
View,
Text,
FlatList,
TouchableOpacity,
StyleSheet,
Alert,
Linking,
SafeAreaView,
RefreshControl,
Modal,
ActivityIndicator,
} from 'react-native';
import { useFocusEffect } from 'expo-router';
import TrackedWebView from '../../components/TrackedWebView';
import {
getSavedHomes,
enrichSavedHomes,
unsaveHome,
type SavedHome,
} from '../../lib/storage';
const BRAND = '#1a3a6e';
const ACCENT = '#e07b39';
const STATUS_COLORS: Record<string, string> = {
MOVE_IN_READY: '#16a34a',
UNDER_CONSTRUCTION: '#e07b39',
PLANNED: '#6366f1',
};
function statusLabel(s?: string): string | null {
if (s === 'MOVE_IN_READY') return 'Move-In Ready';
if (s === 'UNDER_CONSTRUCTION') return 'Under Construction';
if (s === 'PLANNED') return 'Planned';
return null;
}
function formatPrice(price: number | null): string {
if (price === null) return 'Price TBD';
if (price >= 1_000_000) return `$${(price / 1_000_000).toFixed(1)}M`;
if (price >= 1_000) return `$${Math.round(price / 1_000)}k`;
return `$${price}`;
}
function formatDate(iso: string): string {
return new Date(iso).toLocaleDateString(undefined, {
month: 'short',
day: 'numeric',
year: 'numeric',
});
}
interface HomeCardProps {
home: SavedHome;
onRemove: (id: string) => void;
onOpen: (home: SavedHome) => void;
}
function HomeCard({ home, onRemove, onOpen }: HomeCardProps) {
return (
<TouchableOpacity
style={styles.card}
onPress={() => onOpen(home)}
activeOpacity={0.75}
>
<View style={styles.cardHeader}>
<Text style={styles.cardTitle} numberOfLines={2}>
{home.title || 'New Construction Home'}
</Text>
<TouchableOpacity
style={styles.removeBtn}
onPress={() =>
Alert.alert('Remove', 'Remove this home from Saved?', [
{ text: 'Cancel', style: 'cancel' },
{
text: 'Remove',
style: 'destructive',
onPress: () => onRemove(home.id),
},
])
}
hitSlop={{ top: 8, left: 8, bottom: 8, right: 8 }}
>
<Text style={styles.removeBtnText}>✕</Text>
</TouchableOpacity>
</View>
{home.location ? (
<Text style={styles.cardLocation}>📍 {home.location}</Text>
) : null}
<View style={styles.cardFooter}>
<Text style={styles.cardPrice}>{formatPrice(home.price)}</Text>
{statusLabel(home.status) ? (
<Text
style={[
styles.cardStatus,
{ backgroundColor: STATUS_COLORS[home.status as string] ?? BRAND },
]}
>
{statusLabel(home.status)}
</Text>
) : null}
{home.builderName ? (
<Text style={styles.cardBuilder}>{home.builderName}</Text>
) : null}
<Text style={styles.cardSavedAt}>Saved {formatDate(home.savedAt)}</Text>
</View>
</TouchableOpacity>
);
}
function EmptyState() {
return (
<View style={styles.empty}>
<Text style={styles.emptyIcon}>♥</Text>
<Text style={styles.emptyTitle}>No saved homes yet</Text>
<Text style={styles.emptyBody}>
Browse listings and tap{' '}
<Text style={{ fontWeight: '700' }}>Save this Home</Text> to keep track
of homes you love. Your list stays here even without an account.
</Text>
</View>
);
}
export default function SavedTab() {
const [homes, setHomes] = useState<SavedHome[]>([]);
const [refreshing, setRefreshing] = useState(false);
const [activeHome, setActiveHome] = useState<SavedHome | null>(null);
const [modalLoading, setModalLoading] = useState(false);
const load = useCallback(async () => {
// Instant render from local storage...
const saved = await getSavedHomes();
setHomes(saved);
// ...then best-effort enrich price/city/status from /api/map by id.
// enrichSavedHomes only fetches when a saved home is actually missing a
// field, so this is a no-op fetch-wise once everything is enriched.
const enriched = await enrichSavedHomes();
setHomes(enriched);
}, []);
// Reload on every tab focus so Browse saves appear immediately
useFocusEffect(
useCallback(() => {
load();
}, [load]),
);
async function handleRefresh() {
setRefreshing(true);
await load();
setRefreshing(false);
}
async function handleRemove(id: string) {
await unsaveHome(id);
setHomes((prev) => prev.filter((h) => h.id !== id));
}
return (
<SafeAreaView style={styles.container}>
{homes.length === 0 ? (
<EmptyState />
) : (
<FlatList
data={homes}
keyExtractor={(h) => h.id}
contentContainerStyle={styles.list}
refreshControl={
<RefreshControl refreshing={refreshing} onRefresh={handleRefresh} />
}
renderItem={({ item }) => (
<HomeCard
home={item}
onRemove={handleRemove}
onOpen={(h) => {
setActiveHome(h);
setModalLoading(true);
}}
/>
)}
ListHeaderComponent={
<Text style={styles.listHeader}>
{homes.length} saved home{homes.length !== 1 ? 's' : ''}
</Text>
}
/>
)}
{/* In-app modal WebView for viewing a saved home */}
<Modal
visible={activeHome !== null}
animationType="slide"
onRequestClose={() => setActiveHome(null)}
>
<SafeAreaView style={styles.modal}>
<View style={styles.modalHeader}>
<Text style={styles.modalTitle} numberOfLines={1}>
{activeHome?.title || 'Home Details'}
</Text>
<TouchableOpacity
onPress={() => setActiveHome(null)}
style={styles.modalClose}
>
<Text style={styles.modalCloseText}>Done</Text>
</TouchableOpacity>
</View>
{activeHome && (
<TrackedWebView
source={{ uri: activeHome.url }}
onLoadStart={() => setModalLoading(true)}
onLoadEnd={() => setModalLoading(false)}
/>
)}
{modalLoading && (
<View style={styles.modalLoading} pointerEvents="none">
<ActivityIndicator size="large" color={BRAND} />
</View>
)}
</SafeAreaView>
</Modal>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: '#f9fafb' },
list: { padding: 16, gap: 12 },
listHeader: {
fontSize: 13,
color: '#6b7280',
fontWeight: '600',
marginBottom: 8,
},
card: {
backgroundColor: '#fff',
borderRadius: 12,
padding: 16,
shadowColor: '#000',
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.08,
shadowRadius: 4,
elevation: 2,
},
cardHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'flex-start',
marginBottom: 4,
},
cardTitle: {
fontSize: 15,
fontWeight: '700',
color: '#111827',
flex: 1,
marginRight: 8,
},
removeBtn: {
padding: 2,
},
removeBtnText: { fontSize: 14, color: '#9ca3af' },
cardLocation: { fontSize: 13, color: '#6b7280', marginBottom: 8 },
cardFooter: { flexDirection: 'row', alignItems: 'center', gap: 8, flexWrap: 'wrap' },
cardPrice: { fontSize: 16, fontWeight: '700', color: BRAND },
cardStatus: {
fontSize: 11,
fontWeight: '600',
color: '#fff',
paddingHorizontal: 8,
paddingVertical: 2,
borderRadius: 4,
overflow: 'hidden',
},
cardBuilder: {
fontSize: 12,
color: '#fff',
backgroundColor: ACCENT,
paddingHorizontal: 8,
paddingVertical: 2,
borderRadius: 4,
overflow: 'hidden',
},
cardSavedAt: { fontSize: 11, color: '#9ca3af', marginLeft: 'auto' },
empty: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
padding: 40,
},
emptyIcon: { fontSize: 56, marginBottom: 16, color: '#e5e7eb' },
emptyTitle: { fontSize: 20, fontWeight: '700', color: BRAND, marginBottom: 12 },
emptyBody: {
fontSize: 15,
color: '#6b7280',
textAlign: 'center',
lineHeight: 22,
},
modal: { flex: 1, backgroundColor: '#fff' },
modalHeader: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: 16,
paddingVertical: 12,
borderBottomWidth: 1,
borderBottomColor: '#e5e7eb',
},
modalTitle: { fontSize: 16, fontWeight: '600', color: '#111', flex: 1 },
modalClose: {
paddingHorizontal: 12,
paddingVertical: 6,
backgroundColor: BRAND,
borderRadius: 6,
marginLeft: 8,
},
modalCloseText: { color: '#fff', fontWeight: '600', fontSize: 14 },
modalLoading: {
...StyleSheet.absoluteFill,
justifyContent: 'center',
alignItems: 'center',
backgroundColor: 'rgba(255,255,255,0.7)',
},
});