← back to Homesonspec
apps/mobile/app/(tabs)/alerts.tsx
300 lines
/**
* Tab 4 — Alerts (NATIVE — App Store 4.2 differentiator)
*
* Push notification opt-in screen. Users set preferences for new-listing
* alerts. Registers for push permissions via expo-notifications.
*
* IMPORTANT: This tab ONLY registers and persists preferences locally.
* It does NOT send any notifications. Connecting to a backend push
* endpoint is a future wiring step (see the TODO in lib/notifications.ts).
*
* This is native-only value: the website has no equivalent notification
* capability for anonymous users.
*/
import { useCallback, useState } from 'react';
import {
View,
Text,
Switch,
TouchableOpacity,
StyleSheet,
ScrollView,
Alert,
SafeAreaView,
Linking,
Platform,
ActivityIndicator,
} from 'react-native';
import { useFocusEffect } from 'expo-router';
import {
requestPushPermission,
registerForPushNotifications,
getAlertsEnabled,
setAlertsEnabled,
getStoredPushToken,
type PermissionStatus,
} from '../../lib/notifications';
const BRAND = '#1a3a6e';
const ACCENT = '#e07b39';
type SetupState = 'idle' | 'requesting' | 'granted' | 'denied';
export default function AlertsTab() {
const [alertsEnabled, setAlertsEnabledState] = useState(false);
const [permStatus, setPermStatus] = useState<PermissionStatus>('undetermined');
const [setupState, setSetupState] = useState<SetupState>('idle');
const [pushToken, setPushToken] = useState<string | null>(null);
const loadState = useCallback(async () => {
const [enabled, token] = await Promise.all([
getAlertsEnabled(),
getStoredPushToken(),
]);
setAlertsEnabledState(enabled);
setPushToken(token);
if (token) setPermStatus('granted');
}, []);
useFocusEffect(
useCallback(() => {
loadState();
}, [loadState]),
);
async function handleToggle(value: boolean) {
if (value && permStatus !== 'granted') {
// First-time enable — request permission
setSetupState('requesting');
const token = await registerForPushNotifications();
if (token) {
setPushToken(token);
setPermStatus('granted');
setSetupState('granted');
await setAlertsEnabled(true);
setAlertsEnabledState(true);
} else {
setSetupState('denied');
// Check whether truly denied or just simulator
const status = await requestPushPermission();
setPermStatus(status);
if (status === 'denied') {
Alert.alert(
'Notifications Blocked',
'To receive alerts, allow notifications for Homes on Spec in your iPhone Settings.',
[
{ text: 'Open Settings', onPress: () => Linking.openSettings() },
{ text: 'Cancel', style: 'cancel' },
],
);
}
}
} else {
await setAlertsEnabled(value);
setAlertsEnabledState(value);
}
}
return (
<SafeAreaView style={styles.container}>
<ScrollView contentContainerStyle={styles.scroll} showsVerticalScrollIndicator={false}>
{/* Hero */}
<View style={styles.hero}>
<Text style={styles.heroIcon}>🔔</Text>
<Text style={styles.heroTitle}>New Listing Alerts</Text>
<Text style={styles.heroSubtitle}>
New-listing alerts are coming soon — we are rolling delivery out
builder by builder.
</Text>
</View>
{/* Main toggle card */}
<View style={styles.card}>
<View style={styles.toggleRow}>
<View style={styles.toggleLabel}>
<Text style={styles.toggleTitle}>Enable Alerts</Text>
<Text style={styles.toggleDesc}>
Coming soon — new-listing alerts are not available yet
</Text>
</View>
{setupState === 'requesting' ? (
<ActivityIndicator color={BRAND} />
) : (
<Switch
value={false}
disabled={true}
onValueChange={handleToggle}
trackColor={{ false: '#d1d5db', true: BRAND }}
thumbColor={'#f3f4f6'}
/>
)}
</View>
</View>
{/* Status banner */}
{permStatus === 'granted' && alertsEnabled && (
<View style={[styles.statusBanner, styles.statusGranted]}>
<Text style={styles.statusText}>
Notifications are enabled on this device. You are set up to receive
new-listing alerts as we roll delivery out.
</Text>
</View>
)}
{permStatus === 'denied' && (
<View style={[styles.statusBanner, styles.statusDenied]}>
<Text style={styles.statusText}>
Notifications are blocked. Open Settings to allow them.
</Text>
<TouchableOpacity
style={styles.settingsBtn}
onPress={() => Linking.openSettings()}
>
<Text style={styles.settingsBtnText}>Open Settings</Text>
</TouchableOpacity>
</View>
)}
{/* What you will get */}
<View style={styles.section}>
<Text style={styles.sectionTitle}>Coming soon</Text>
{[
['New listings', 'Alert when a builder publishes a matching home'],
['Price drops', 'When a saved home drops in price'],
['Move-in ready', 'When a home you are watching becomes move-in ready'],
].map(([title, desc]) => (
<View key={title} style={styles.featureRow}>
<Text style={styles.featureDot}>•</Text>
<View style={{ flex: 1 }}>
<Text style={styles.featureTitle}>{title}</Text>
<Text style={styles.featureDesc}>{desc}</Text>
</View>
</View>
))}
</View>
{/* Privacy note */}
<View style={styles.privacyNote}>
<Text style={styles.privacyText}>
Your push token is stored only on this device. No account required.
We never sell your data. Unsubscribe any time by disabling the toggle above.
</Text>
</View>
{/* Dev info — visible only in __DEV__ */}
{__DEV__ && pushToken && (
<View style={styles.devCard}>
<Text style={styles.devTitle}>Dev: Push Token</Text>
<Text style={styles.devToken} selectable>
{pushToken.slice(0, 40)}…
</Text>
<Text style={styles.devNote}>
Wire this token to homesonspec.com/api/push/register when ready.
</Text>
</View>
)}
{/* Future: granular filters — market, price range, beds */}
<View style={styles.comingSoon}>
<Text style={styles.comingSoonTitle}>Coming soon</Text>
<Text style={styles.comingSoonBody}>
Filter alerts by market (state / city), price range, and bed count.
Set up your ideal search once and we will alert you automatically.
</Text>
</View>
</ScrollView>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: '#f9fafb' },
scroll: { padding: 20, gap: 16 },
hero: {
alignItems: 'center',
paddingVertical: 24,
paddingHorizontal: 16,
},
heroIcon: { fontSize: 52, marginBottom: 12 },
heroTitle: { fontSize: 24, fontWeight: '800', color: BRAND, marginBottom: 8, textAlign: 'center' },
heroSubtitle: {
fontSize: 15,
color: '#6b7280',
textAlign: 'center',
lineHeight: 22,
},
card: {
backgroundColor: '#fff',
borderRadius: 12,
padding: 16,
shadowColor: '#000',
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.07,
shadowRadius: 3,
elevation: 2,
},
toggleRow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
},
toggleLabel: { flex: 1, marginRight: 12 },
toggleTitle: { fontSize: 16, fontWeight: '700', color: '#111827' },
toggleDesc: { fontSize: 13, color: '#6b7280', marginTop: 2 },
statusBanner: {
borderRadius: 10,
padding: 14,
},
statusGranted: { backgroundColor: '#d1fae5' },
statusDenied: { backgroundColor: '#fee2e2' },
statusText: { fontSize: 13, color: '#374151', lineHeight: 18 },
settingsBtn: {
marginTop: 8,
backgroundColor: BRAND,
paddingHorizontal: 16,
paddingVertical: 8,
borderRadius: 6,
alignSelf: 'flex-start',
},
settingsBtnText: { color: '#fff', fontWeight: '600', fontSize: 13 },
section: {
backgroundColor: '#fff',
borderRadius: 12,
padding: 16,
gap: 12,
},
sectionTitle: { fontSize: 14, fontWeight: '700', color: BRAND, marginBottom: 4 },
featureRow: { flexDirection: 'row', gap: 10, alignItems: 'flex-start' },
featureDot: { color: ACCENT, fontSize: 18, lineHeight: 20, marginTop: -1 },
featureTitle: { fontSize: 14, fontWeight: '600', color: '#111827' },
featureDesc: { fontSize: 13, color: '#6b7280', marginTop: 1 },
privacyNote: {
padding: 14,
backgroundColor: '#f3f4f6',
borderRadius: 10,
},
privacyText: { fontSize: 12, color: '#9ca3af', lineHeight: 18 },
devCard: {
backgroundColor: '#fef9c3',
borderRadius: 8,
padding: 12,
borderLeftWidth: 3,
borderLeftColor: '#eab308',
},
devTitle: { fontSize: 11, fontWeight: '700', color: '#713f12', marginBottom: 4 },
devToken: { fontSize: 10, color: '#451a03', fontFamily: Platform.select({ ios: 'Menlo', android: 'monospace' }) },
devNote: { fontSize: 10, color: '#92400e', marginTop: 4 },
comingSoon: {
backgroundColor: '#eff6ff',
borderRadius: 10,
padding: 16,
borderWidth: 1,
borderColor: '#bfdbfe',
},
comingSoonTitle: { fontSize: 13, fontWeight: '700', color: BRAND, marginBottom: 4 },
comingSoonBody: { fontSize: 13, color: '#3b82f6', lineHeight: 18 },
});