whisper-money/resources/js/app.tsx

303 lines
10 KiB
TypeScript

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<string>();
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 (
<Toaster
richColors
position={isOnboarding ? 'bottom-center' : undefined}
mobileOffset={{ bottom: isOnboarding ? '16px' : '110px' }}
icons={{
success: <CircleCheckIcon className="size-4" />,
info: <InfoIcon className="size-4" />,
warning: <TriangleAlertIcon className="size-4" />,
error: <OctagonXIcon className="size-4" />,
loading: <Loader2Icon className="size-4 animate-spin" />,
}}
/>
);
}
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<SharedData>
| 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<SharedData>) => {
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<string, string>) ?? {},
);
// 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<string, string>) ?? {},
);
showSubscriptionPaymentIssueToast(
pageProps.subscriptionPaymentIssue,
);
void syncUserTimezone(pageProps);
});
showSubscriptionPaymentIssueToast(initialSubscriptionPaymentIssue);
void syncUserTimezone(initialPageProps);
root.render(
<StrictMode>
<EncryptionKeyProvider
hasEncryptionSetup={
hasEncryptionSetup &&
(hasEncryptedAccounts || hasEncryptedTransactions)
}
>
<PrivacyModeProvider>
<SyncProvider
initialIsAuthenticated={initialIsAuthenticated}
initialUser={initialUser}
>
<App {...props} />
<ExpiredConnectionsToast
initialExpiredConnections={
initialExpiredConnections
}
/>
<AppToaster />
</SyncProvider>
</PrivacyModeProvider>
</EncryptionKeyProvider>
</StrictMode>,
);
},
progress: {
color: getProgressBarColor(),
},
});