// Polyfills must be imported FIRST before any other React Native imports import "../src/polyfills/web"; // Initialize Crashlytics early to capture all errors import { initCrashlytics, setUserContext, clearUserContext } from "../src/lib/crashlytics"; initCrashlytics(); // Install the Observe global JS error handler after Crashlytics so it wraps // (and always calls) whatever handler is already registered. import { installObserveGlobalErrorHandler, logAppStarted } from "../src/lib/observe"; installObserveGlobalErrorHandler(); // Analytics imports (initialization deferred until app mounts) import { initAnalytics, setUserId, clearUserId, logScreenView, Events } from "../src/lib/analytics"; // Import push notification utilities import { clearPushToken, setupNotificationResponseListener, getInitialNotification, handleNotificationResponse, } from "../src/lib/notifications"; import React, { useEffect, useRef, useState } from "react"; import { Platform, View, AppState, AppStateStatus, Modal, Text, StyleSheet, Pressable, TouchableOpacity } from "react-native"; // Suppress useLayoutEffect warning on server-side rendering if (Platform.OS === "web" && typeof globalThis.window === "undefined") { React.useLayoutEffect = useEffect; // Also silence the warning in case some libraries ignore the monkey-patch const originalError = console.error; console.error = (...args) => { if (typeof args[0] === "string" && args[0].includes("useLayoutEffect does nothing on the server")) { return; } originalError(...args); }; } import { Stack, router, usePathname } from "expo-router"; import { ReduceMotion, ReducedMotionConfig } from "react-native-reanimated"; import { StatusBar } from "expo-status-bar"; import { Ionicons } from "@expo/vector-icons"; // GestureHandlerRootView is only needed on native platforms // On web, we use a simple View to avoid findNodeHandle errors const GestureHandlerRootView = Platform.OS === "web" ? View : require("react-native-gesture-handler").GestureHandlerRootView; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import * as Linking from "expo-linking"; import { useAuthStore } from "../src/store"; import { authClient } from "../src/lib/authClient"; import { colors, spacing, radius, typography } from "../src/theme"; import { hasSeenGuestAccountWarning, markGuestAccountWarningSeen } from "../src/lib/guestAccountWarningSeen"; import { parseInviteLinkCode } from "../src/lib/inviteLink"; import { stashPendingInviteCode } from "../src/lib/pendingInviteCode"; import { needsOnboarding } from "../src/constants/onboarding"; const queryClient = new QueryClient(); export default function RootLayout() { const { fetchUser, setUser, isAuthenticated, isAnonymous } = useAuthStore(); const pathname = usePathname(); const appState = useRef(AppState.currentState); const analyticsInitialized = useRef(false); const [showGuestWarning, setShowGuestWarning] = useState(false); const [checkingGuestWarning, setCheckingGuestWarning] = useState(false); const dismissGuestWarning = async () => { await markGuestAccountWarningSeen(); setShowGuestWarning(false); }; // Initialize analytics after native modules are ready useEffect(() => { if (!analyticsInitialized.current) { analyticsInitialized.current = true; initAnalytics(); Events.appOpened("cold"); logAppStarted(); } }, []); // Track screen views useEffect(() => { if (pathname) { logScreenView(pathname); } }, [pathname]); // Warn guest users (first time only) that accounts are not recoverable useEffect(() => { if (!isAuthenticated || !isAnonymous) return; if (checkingGuestWarning) return; if (pathname?.startsWith("/(auth)")) return; setCheckingGuestWarning(true); hasSeenGuestAccountWarning() .then((seen) => { if (!seen) { setShowGuestWarning(true); } }) .finally(() => setCheckingGuestWarning(false)); }, [isAuthenticated, isAnonymous, pathname, checkingGuestWarning]); // Track warm starts when app comes back from background useEffect(() => { const subscription = AppState.addEventListener("change", (nextAppState: AppStateStatus) => { if (appState.current.match(/inactive|background/) && nextAppState === "active") { Events.appOpened("warm"); } appState.current = nextAppState; }); return () => subscription.remove(); }, []); useEffect(() => { // Fetch user on mount fetchUser(); // Handle initial URL if app was opened via deep link const handleDeepLink = async (url: string | null) => { if (!url) return; // Invite links (https://sauci.app/join/{code} or app.sauci://join?code={code}) // are handled separately from the Supabase auth deep link flow below. const inviteLink = parseInviteLinkCode(url); if (inviteLink) { Events.inviteLinkOpened(inviteLink.source); const { isAuthenticated, user } = useAuthStore.getState(); const onboardingPending = needsOnboarding(user?.onboarding_completed, user?.onboarding_version); if (user?.couple_id) { await import("../src/lib/pendingInviteCode").then(({ clearPendingInviteCode }) => clearPendingInviteCode(user.id)); if (user.couple_id) { router.push({ pathname: "/(app)/pairing", params: { incomingCode: inviteLink.code } }); } } else { await stashPendingInviteCode(inviteLink.code, user?.id ?? null); if (isAuthenticated && !onboardingPending) { router.push({ pathname: "/(app)/pairing", params: { code: inviteLink.code } }); } } return; } try { // Parse the URL to extract auth parameters const parsedUrl = new URL(url); console.log('[DeepLink] Parsed URL:', { protocol: parsedUrl.protocol, host: parsedUrl.host, pathname: parsedUrl.pathname, search: parsedUrl.search, hash: parsedUrl.hash, }); const params = new URLSearchParams(parsedUrl.search); // Also check hash fragment (some auth flows use fragments) const hashParams = new URLSearchParams(parsedUrl.hash.replace('#', '')); // Get token parameters - check both query and hash const accessToken = params.get('access_token') || hashParams.get('access_token'); const refreshToken = params.get('refresh_token') || hashParams.get('refresh_token'); const tokenHash = params.get('token_hash') || hashParams.get('token_hash'); const type = params.get('type') || hashParams.get('type'); console.log('[DeepLink] Extracted params:', { accessToken: accessToken ? 'present' : 'missing', refreshToken: refreshToken ? 'present' : 'missing', tokenHash: tokenHash ? 'present' : 'missing', type, }); if (accessToken && refreshToken) { // OAuth flow - set session directly console.log('[DeepLink] Setting session with tokens'); const { data, error } = await authClient.auth.setSession({ access_token: accessToken, refresh_token: refreshToken, }); console.log('[DeepLink] setSession result:', { hasSession: !!data?.session, userId: data?.session?.user?.id, error: error?.message }); } else if (tokenHash && type) { // Magic link / email verification flow - verify the OTP console.log('[DeepLink] Verifying OTP with token_hash and type:', type); const { data, error } = await authClient.auth.verifyOtp({ token_hash: tokenHash, type: type as 'signup' | 'magiclink' | 'recovery' | 'invite' | 'email', }); console.log('[DeepLink] verifyOtp result:', { data: !!data?.session, error: error?.message }); // If this is a password recovery flow, navigate to reset password screen if (type === 'recovery' && data?.session && !error) { console.log('[DeepLink] Password recovery flow - navigating to reset-password'); router.replace("/(auth)/reset-password"); } } else { // Fallback for web or other scenarios console.log('[DeepLink] No auth params found, falling back to getSession'); await authClient.auth.getSession(); } } catch (error) { console.error('[DeepLink] Error handling deep link:', error); // Still try to get session as fallback await authClient.auth.getSession(); } }; // Get initial URL Linking.getInitialURL().then((url) => { handleDeepLink(url); }); // Listen for new deep links while app is open const linkingSubscription = Linking.addEventListener("url", (event) => { handleDeepLink(event.url); }); // Set up push notification response listener (for taps while app is running) const notificationSubscription = setupNotificationResponseListener(); // Check if app was opened from a notification tap (cold start) getInitialNotification().then((response) => { if (response) { handleNotificationResponse(response); } }); // Listen for auth changes const { data: { subscription: authSubscription } } = authClient.auth.onAuthStateChange( async (event, session) => { console.log('[Auth] onAuthStateChange:', event, 'hasSession:', !!session, 'userId:', session?.user?.id); if (event === "SIGNED_IN" || event === "TOKEN_REFRESHED" || event === "USER_UPDATED") { console.log('[Auth] Calling fetchUser...'); fetchUser(); // Set Crashlytics user context for error tracking if (session?.user) { setUserContext(session.user.id, session.user.email); // Set Analytics user ID setUserId(session.user.id); } } else if (event === "SIGNED_OUT") { // Clear push token before clearing user state const userId = useAuthStore.getState().user?.id; if (userId) { await clearPushToken(userId); } setUser(null); clearUserContext(); clearUserId(); // Clear React Query cache queryClient.clear(); } } ); return () => { linkingSubscription.remove(); authSubscription.unsubscribe(); notificationSubscription.remove(); }; }, []); return ( { }}> Your account isn't saved If you delete the app or switch devices, you won't be able to recover this account. Save your account now to protect your couple, matches, and purchases. { await markGuestAccountWarningSeen(); setShowGuestWarning(false); router.push("/(app)/settings/save-account" as any); }} activeOpacity={0.8} > Save account { await markGuestAccountWarningSeen(); setShowGuestWarning(false); }} activeOpacity={0.8} > Not now ); } const styles = StyleSheet.create({ guestWarningOverlay: { flex: 1, justifyContent: "center", alignItems: "center", padding: spacing.lg, backgroundColor: "rgba(0, 0, 0, 0.75)", }, guestWarningCard: { width: "100%", maxWidth: 420, backgroundColor: colors.backgroundLight, borderRadius: radius.xl, padding: spacing.lg, borderWidth: 1, borderColor: colors.glass.border, }, guestWarningHeader: { flexDirection: "row", alignItems: "center", justifyContent: "space-between", marginBottom: spacing.md, }, guestWarningIcon: { width: 40, height: 40, borderRadius: 20, backgroundColor: colors.primaryLight, justifyContent: "center", alignItems: "center", }, guestWarningClose: { width: 40, height: 40, borderRadius: 20, backgroundColor: colors.glass.background, justifyContent: "center", alignItems: "center", }, guestWarningTitle: { ...typography.headline, color: colors.text, marginBottom: spacing.sm, }, guestWarningBody: { ...typography.body, color: colors.textSecondary, lineHeight: 22, marginBottom: spacing.lg, }, guestWarningActions: { gap: spacing.sm, }, guestWarningPrimary: { backgroundColor: colors.primary, paddingVertical: 14, borderRadius: radius.md, alignItems: "center", }, guestWarningPrimaryText: { ...typography.subhead, color: colors.text, fontWeight: "600", }, guestWarningSecondary: { backgroundColor: colors.glass.background, paddingVertical: 14, borderRadius: radius.md, alignItems: "center", borderWidth: 1, borderColor: colors.glass.border, }, guestWarningSecondaryText: { ...typography.subhead, color: colors.textSecondary, fontWeight: "600", }, });