import '../css/app.css'; import { createInertiaApp, router } from '@inertiajs/react'; import * as Sentry from '@sentry/react'; import axios from 'axios'; import { resolvePageComponent } from 'laravel-vite-plugin/inertia-helpers'; import { CircleCheckIcon, InfoIcon, Loader2Icon, OctagonXIcon, TriangleAlertIcon, } from 'lucide-react'; import { StrictMode, useEffect, useState } from 'react'; import { createRoot } from 'react-dom/client'; import { toast, Toaster } from 'sonner'; import { EncryptionKeyProvider } from './contexts/encryption-key-context'; import { PrivacyModeProvider } from './contexts/privacy-mode-context'; import { SyncProvider } from './contexts/sync-context'; import { initializeTheme } from './hooks/use-appearance'; import { initializeChartColorScheme } from './hooks/use-chart-color-scheme'; import { installChunkLoadRecovery } from './lib/chunk-load-recovery'; import { initializePostHog } from './lib/posthog'; import { isBrowserExtensionNoise, isChunkLoadErrorEvent, isFacebookInAppBrowserJavaBridgeNoise, isPostMessageDataCloneNoise, isSafariCashbackExtensionNoise, } from './lib/sentry'; import { showSubscriptionPaymentIssueToast } from './lib/subscription-payment-issue-toast'; import type { ExpiredBankingConnectionNotification, SharedData } from './types'; import { __, setTranslations } from './utils/i18n'; installChunkLoadRecovery(); // The installed PWA claims the whole origin (manifest scope "/"), so on mobile an // OAuth link (e.g. from ChatGPT connecting over MCP) gets captured into the app. // Paired with launch_handler "focus-existing", the launcher hands us the target // URL instead of dropping it and showing the dashboard — navigate there so the // OAuth consent screen actually loads. Non-OAuth launches keep their place. type LaunchParams = { targetURL?: string }; const launchQueue = ( window as Window & { launchQueue?: { setConsumer(consumer: (params: LaunchParams) => void): void; }; } ).launchQueue; if (launchQueue) { launchQueue.setConsumer(({ targetURL }) => { if (!targetURL) { return; } const target = new URL(targetURL); if ( target.pathname.startsWith('/oauth/') && target.href !== window.location.href ) { window.location.href = targetURL; } }); } Sentry.init({ dsn: import.meta.env.SENTRY_LARAVEL_DSN, environment: import.meta.env.MODE, integrations: [], tracesSampleRate: 0, sendDefaultPii: true, beforeSend(event) { if ( isChunkLoadErrorEvent(event) || isBrowserExtensionNoise(event) || isPostMessageDataCloneNoise(event) || isFacebookInAppBrowserJavaBridgeNoise(event) || isSafariCashbackExtensionNoise(event) ) { return null; } return event; }, enabled: import.meta.env.MODE === 'production' && Boolean(import.meta.env.SENTRY_LARAVEL_DSN), }); initializePostHog(); // Initialize theme before creating the app so progress bar color is correct initializeTheme(); initializeChartColorScheme(); const appName = import.meta.env.VITE_APP_NAME || 'Laravel'; let hasAttemptedTimezoneBackfill = false; const notifiedExpiredConnectionIds = new Set(); function showExpiredConnectionsToast( expiredConnections: ExpiredBankingConnectionNotification[] | undefined, ): void { if (!expiredConnections || expiredConnections.length === 0) { return; } const newExpiredConnections = expiredConnections.filter( (connection) => !notifiedExpiredConnectionIds.has(connection.id), ); if (newExpiredConnections.length === 0) { return; } expiredConnections.forEach((connection) => { notifiedExpiredConnectionIds.add(connection.id); }); const firstConnection = expiredConnections[0]; const count = expiredConnections.length; toast.error( count === 1 ? __('Your :provider connection has expired.', { provider: firstConnection.aspsp_name, }) : __('You have :count expired bank connections.', { count, }), { description: __('Reconnect to resume automatic syncing.'), duration: Infinity, action: { label: __('Reconnect'), onClick: () => { window.location.href = firstConnection.reconnect_url; }, }, }, ); } function ExpiredConnectionsToast({ initialExpiredConnections, }: { initialExpiredConnections: ExpiredBankingConnectionNotification[]; }) { useEffect(() => { showExpiredConnectionsToast(initialExpiredConnections); return router.on('navigate', (event) => { const pageProps = event.detail.page.props as unknown as SharedData; showExpiredConnectionsToast(pageProps.expiredBankingConnections); }); }, [initialExpiredConnections]); return null; } // Determine progress bar color based on current theme const getProgressBarColor = () => { const isDark = document.documentElement.classList.contains('dark'); return isDark ? '#EEE' : '#4B5563'; // gray-400 for dark mode, gray-600 for light mode }; const isOnboardingPath = () => typeof window !== 'undefined' && window.location.pathname.startsWith('/onboarding'); // Onboarding has no bottom navigation bar, so toasts sit flush at the bottom // center instead of being lifted to clear the (absent) mobile tab bar. function AppToaster() { const [isOnboarding, setIsOnboarding] = useState(isOnboardingPath); useEffect(() => { return router.on('navigate', () => setIsOnboarding(isOnboardingPath())); }, []); return ( , info: , warning: , error: , loading: , }} /> ); } createInertiaApp({ title: (title) => (title ? `${title} - ${appName}` : appName), resolve: (name) => resolvePageComponent( `./pages/${name}.tsx`, import.meta.glob('./pages/**/*.tsx'), ), setup({ el, App, props }) { const root = createRoot(el); const initialPageProps = props.initialPage?.props as | Partial | undefined; const initialUser = initialPageProps?.auth?.user ?? null; const initialIsAuthenticated = Boolean(initialUser); const hasEncryptionSetup = (initialPageProps?.hasEncryptionSetup as boolean) ?? false; const hasEncryptedAccounts = (initialPageProps?.hasEncryptedAccounts as boolean) ?? false; const hasEncryptedTransactions = (initialPageProps?.hasEncryptedTransactions as boolean) ?? false; const initialExpiredConnections = (initialPageProps?.expiredBankingConnections as | ExpiredBankingConnectionNotification[] | undefined) ?? []; const initialSubscriptionPaymentIssue = initialPageProps?.subscriptionPaymentIssue; const syncUserTimezone = async (pageProps?: Partial) => { const user = pageProps?.auth?.user ?? null; const detectedTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone; if ( hasAttemptedTimezoneBackfill || !user || user.timezone || !detectedTimezone ) { return; } hasAttemptedTimezoneBackfill = true; try { await axios.patch('/settings/timezone', { timezone: detectedTimezone, }); } catch { hasAttemptedTimezoneBackfill = false; } }; // Initialize translations from server-rendered page data setTranslations( (initialPageProps?.translations as Record) ?? {}, ); // Keep translations in sync on every Inertia navigation router.on('navigate', (event) => { const pageProps = event.detail.page.props as unknown as SharedData; setTranslations( (pageProps?.translations as Record) ?? {}, ); showSubscriptionPaymentIssueToast( pageProps.subscriptionPaymentIssue, ); void syncUserTimezone(pageProps); }); showSubscriptionPaymentIssueToast(initialSubscriptionPaymentIssue); void syncUserTimezone(initialPageProps); root.render( , ); }, progress: { color: getProgressBarColor(), }, });