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