← back to Homesonspec

apps/mobile/app/(tabs)/browse.tsx

265 lines

/**
 * Tab 1 — Browse
 *
 * React-Native WebView shell of https://homesonspec.com.
 * Injects CSS to hide the site's sticky header/nav so the content feels
 * app-native inside the tab bar chrome.
 *
 * Native value delivered here:
 *  - Handles network errors with a native retry screen (not a browser error page).
 *  - Loading progress indicator is native (ActivityIndicator).
 *  - Intercepts home-detail URLs to offer a "Save this home" native action
 *    (see onMessage handler and the injected JS).
 */

import { useRef, useState } from 'react';
import {
  View,
  Text,
  ActivityIndicator,
  TouchableOpacity,
  StyleSheet,
  SafeAreaView,
  Alert,
} from 'react-native';
import type { WebView, WebViewNavigation } from 'react-native-webview';
import TrackedWebView from '../../components/TrackedWebView';
import { saveHome } from '../../lib/storage';
import { WEB_BASE_URL } from '../../lib/api';


const BRAND = '#1a3a6e';
const ACCENT = '#e07b39';

/**
 * Tracker hosts the app refuses to talk to, so it truthfully collects nothing.
 * Kept in sync between the JS-level interceptor (below) and the native
 * onShouldStartLoadWithRequest filter.
 */

/**
 * CSS injected after every page load to hide the site's sticky header/nav.
 * Guards with try/catch so a future site selector change doesn't crash.
 * Also ensures the save-home button injected below is visible.
 */
const INJECTED_CSS = `
(function() {
  try {
    // Hide sticky site header (nav bar) — best-effort; guard if selectors change
    var style = document.createElement('style');
    style.id = 'hos-native-overrides';
    style.textContent = [
      'header { display: none !important; }',
      // Banner strip below header
      '.bg-brand-950 { display: none !important; }',
      // Give body top breathing room now that nav is gone
      'main { padding-top: 8px !important; }',
      // Hide the site's "Add to Home Screen" PWA install prompt — the native
      // app already IS the install; the web banner is redundant/confusing here.
      '[data-hos-install-prompt] { display: none !important; }',
    ].join('\\n');
    document.head.appendChild(style);
  } catch(e) {}
  true; // required for RN WebView injectedJavaScript
})();
`;

/**
 * Injected JS that posts a message to RN when the user taps a home-detail page.
 * Verified against the LIVE PDP (/homes/:id, Cycle 2): the h1 holds the home's
 * street address (present in SSR) and is reliable. There are NO
 * data-price/data-location attributes, and a naive first-"$NNN,NNN" text scan
 * grabs the WRONG number (a "From $X" base price or a "$X/mo" payment banner),
 * so we deliberately do NOT scrape price/location from the DOM — a confidently
 * wrong price is worse than a blank one. We capture only the reliable title + url.
 * Price/city/status are filled correctly in Cycle 3 by looking the home up by id
 * in the /api/map dataset the app already loads.
 */
const INJECTED_SAVE_BRIDGE = `
(function() {
  try {
    var h1 = document.querySelector('h1');
    window.ReactNativeWebView && window.ReactNativeWebView.postMessage(JSON.stringify({
      type: 'PAGE_CHANGE',
      url: window.location.href,
      title: (h1 && h1.innerText) || '',
      price: '',
      location: '',
    }));
  } catch(e) {}
  true;
})();
`;

interface PageInfo {
  url: string;
  title: string;
  price: string;
  location: string;
}

export default function BrowseTab() {
  const webViewRef = useRef<WebView>(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<string | null>(null);
  const [currentPage, setCurrentPage] = useState<PageInfo | null>(null);

  const isHomePage = currentPage?.url?.match(/\/homes\/[a-zA-Z0-9]+/);

  function handleMessage(event: { nativeEvent: { data: string } }) {
    try {
      const msg = JSON.parse(event.nativeEvent.data);
      if (msg.type === 'PAGE_CHANGE') {
        setCurrentPage(msg);
      }
    } catch {
      // ignore non-JSON messages
    }
  }

  function handleSaveHome() {
    if (!currentPage) return;
    const idMatch = currentPage.url.match(/\/homes\/([a-zA-Z0-9]+)/);
    const id = idMatch ? idMatch[1] : currentPage.url;

    saveHome({
      id,
      title: currentPage.title || 'New Construction Home',
      location: currentPage.location || '',
      builderSlug: '',
      builderName: '',
      price: currentPage.price ? parseInt(currentPage.price, 10) : null,
      url: currentPage.url,
    })
      .then(() =>
        Alert.alert('Saved!', 'This home was added to your Saved Homes tab.'),
      )
      .catch(() => Alert.alert('Error', 'Could not save this home.'));
  }

  function handleNavChange(nav: WebViewNavigation) {
    // Inject the save bridge JS after every navigation
    webViewRef.current?.injectJavaScript(INJECTED_SAVE_BRIDGE);
  }

  if (error) {
    return (
      <SafeAreaView style={styles.centered}>
        <Text style={styles.errorTitle}>No Connection</Text>
        <Text style={styles.errorBody}>
          Could not load HomesOnSpec. Check your connection and try again.
        </Text>
        <TouchableOpacity
          style={styles.retryBtn}
          onPress={() => {
            setError(null);
            setLoading(true);
            webViewRef.current?.reload();
          }}
        >
          <Text style={styles.retryBtnText}>Retry</Text>
        </TouchableOpacity>
      </SafeAreaView>
    );
  }

  return (
    <View style={styles.container}>
      <TrackedWebView
        ref={webViewRef}
        source={{ uri: WEB_BASE_URL }}
        style={styles.webview}
        onLoadStart={() => setLoading(true)}
        onLoadEnd={() => setLoading(false)}
        onError={(e) => {
          setLoading(false);
          setError(e.nativeEvent.description);
        }}
        onHttpError={(e) => {
          if (e.nativeEvent.statusCode >= 500) {
            setError(`Server error ${e.nativeEvent.statusCode}`);
          }
        }}
        injectedJavaScript={INJECTED_CSS}
        injectedJavaScriptBeforeContentLoaded={INJECTED_CSS}
        onNavigationStateChange={handleNavChange}
        onMessage={handleMessage}
        // Allow mixed content for any embedded widgets
        mixedContentMode="compatibility"
        // Let all homesonspec.com links stay in-app
        allowsInlineMediaPlayback
        mediaPlaybackRequiresUserAction={false}
        // Pull-to-refresh feel
        bounces
        showsVerticalScrollIndicator={false}
      />

      {loading && (
        <View style={styles.loadingOverlay} pointerEvents="none">
          <ActivityIndicator size="large" color={BRAND} />
        </View>
      )}

      {/* Native "Save this home" overlay — appears on home detail pages */}
      {isHomePage && !loading && (
        <TouchableOpacity style={styles.saveBtn} onPress={handleSaveHome}>
          <Text style={styles.saveBtnText}>♥  Save this Home</Text>
        </TouchableOpacity>
      )}
    </View>
  );
}

const styles = StyleSheet.create({
  container: { flex: 1, backgroundColor: '#fff' },
  webview: { flex: 1 },
  loadingOverlay: {
    ...StyleSheet.absoluteFill,
    justifyContent: 'center',
    alignItems: 'center',
    backgroundColor: 'rgba(255,255,255,0.7)',
  },
  centered: {
    flex: 1,
    justifyContent: 'center',
    alignItems: 'center',
    padding: 32,
    backgroundColor: '#f9fafb',
  },
  errorTitle: {
    fontSize: 20,
    fontWeight: '700',
    color: BRAND,
    marginBottom: 8,
  },
  errorBody: {
    fontSize: 15,
    color: '#6b7280',
    textAlign: 'center',
    lineHeight: 22,
    marginBottom: 24,
  },
  retryBtn: {
    backgroundColor: BRAND,
    paddingHorizontal: 32,
    paddingVertical: 12,
    borderRadius: 8,
  },
  retryBtnText: { color: '#fff', fontWeight: '700', fontSize: 16 },
  saveBtn: {
    position: 'absolute',
    bottom: 16,
    alignSelf: 'center',
    backgroundColor: ACCENT,
    paddingHorizontal: 28,
    paddingVertical: 13,
    borderRadius: 28,
    shadowColor: '#000',
    shadowOffset: { width: 0, height: 2 },
    shadowOpacity: 0.18,
    shadowRadius: 6,
    elevation: 4,
  },
  saveBtnText: { color: '#fff', fontWeight: '700', fontSize: 16 },
});