← 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)',
  },
});