diff --git a/src/App.tsx b/src/App.tsx
index c18a80c4..bf223a46 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -9,6 +9,7 @@ import * as Cronitor from '@cronitorio/cronitor-rum';
import { AuthProvider } from "@/hooks/useAuth";
import { AuthModalProvider } from "@/contexts/AuthModalContext";
import { MFAStepUpProvider } from "@/contexts/MFAStepUpContext";
+import { CronitorHealthProvider, useCronitorHealth } from "@/contexts/CronitorHealthContext";
import { LocationAutoDetectProvider } from "@/components/providers/LocationAutoDetectProvider";
import { AnalyticsWrapper } from "@/components/analytics/AnalyticsWrapper";
import { Footer } from "@/components/layout/Footer";
@@ -19,6 +20,8 @@ import { EntityErrorBoundary } from "@/components/error/EntityErrorBoundary";
import { breadcrumb } from "@/lib/errorBreadcrumbs";
import { handleError } from "@/lib/errorHandler";
import { RetryStatusIndicator } from "@/components/ui/retry-status-indicator";
+import { APIStatusBanner } from "@/components/ui/api-status-banner";
+import { cn } from "@/lib/utils";
// Core routes (eager-loaded for best UX)
import Index from "./pages/Index";
@@ -142,9 +145,15 @@ function AppContent(): React.JSX.Element {
console.log('[Cronitor] RUM initialized');
}, []);
+ // Check if API status banner is visible to add padding
+ const { passing, isBannerDismissed } = useCronitorHealth();
+ const showBanner = passing === false && !isBannerDismissed;
+
return (
-
+
+
+
@@ -395,7 +404,8 @@ function AppContent(): React.JSX.Element {
-
+
+
);
}
@@ -404,9 +414,11 @@ const App = (): React.JSX.Element => (
-
-
-
+
+
+
+
+
diff --git a/src/components/ui/api-status-banner.tsx b/src/components/ui/api-status-banner.tsx
new file mode 100644
index 00000000..750a4fd9
--- /dev/null
+++ b/src/components/ui/api-status-banner.tsx
@@ -0,0 +1,56 @@
+import { AlertTriangle, X, ExternalLink } from 'lucide-react';
+import { Button } from '@/components/ui/button';
+import { useCronitorHealth } from '@/contexts/CronitorHealthContext';
+
+/**
+ * Banner displayed when Cronitor detects Supabase API is down
+ * Includes link to status page and dismissal option
+ */
+export function APIStatusBanner() {
+ const { passing, isLoading, isBannerDismissed, dismissBanner } = useCronitorHealth();
+
+ // Don't show if:
+ // - Still loading initial data
+ // - API is healthy (passing === true)
+ // - User dismissed it
+ // - Status unknown (passing === null) after initial load
+ if (isLoading || passing === true || passing === null || isBannerDismissed) {
+ return null;
+ }
+
+ return (
+
+
+
+
+
+
+
API Monitoring Alert
+
+ Supabase services are experiencing issues. Some features may be temporarily unavailable.
+
+
+ View Status Page
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/contexts/CronitorHealthContext.tsx b/src/contexts/CronitorHealthContext.tsx
new file mode 100644
index 00000000..59f9f7de
--- /dev/null
+++ b/src/contexts/CronitorHealthContext.tsx
@@ -0,0 +1,63 @@
+import { createContext, useContext, ReactNode, useState, useEffect } from 'react';
+import { useCronitorHealth as useCronitorHealthQuery } from '@/hooks/useCronitorHealth';
+
+interface CronitorHealthContextType {
+ passing: boolean | null; // null = loading/unknown
+ isLoading: boolean;
+ error: Error | null;
+ lastChecked: Date | null;
+ isBannerDismissed: boolean;
+ dismissBanner: () => void;
+}
+
+const CronitorHealthContext = createContext(undefined);
+
+const DISMISSAL_DURATION = 15 * 60 * 1000; // 15 minutes
+const DISMISSAL_KEY = 'cronitor-banner-dismissed';
+
+export function CronitorHealthProvider({ children }: { children: ReactNode }) {
+ const { data, isLoading, error } = useCronitorHealthQuery();
+ const [dismissedUntil, setDismissedUntil] = useState(() => {
+ const stored = localStorage.getItem(DISMISSAL_KEY);
+ return stored ? parseInt(stored) : null;
+ });
+
+ const dismissBanner = () => {
+ const until = Date.now() + DISMISSAL_DURATION;
+ localStorage.setItem(DISMISSAL_KEY, until.toString());
+ setDismissedUntil(until);
+ };
+
+ const isBannerDismissed = dismissedUntil ? Date.now() < dismissedUntil : false;
+
+ // Auto-clear dismissal when API is healthy again
+ useEffect(() => {
+ if (data?.passing === true && dismissedUntil) {
+ localStorage.removeItem(DISMISSAL_KEY);
+ setDismissedUntil(null);
+ }
+ }, [data?.passing, dismissedUntil]);
+
+ return (
+
+ {children}
+
+ );
+}
+
+export function useCronitorHealth() {
+ const context = useContext(CronitorHealthContext);
+ if (context === undefined) {
+ throw new Error('useCronitorHealth must be used within CronitorHealthProvider');
+ }
+ return context;
+}
diff --git a/src/hooks/useCronitorHealth.ts b/src/hooks/useCronitorHealth.ts
new file mode 100644
index 00000000..e3063329
--- /dev/null
+++ b/src/hooks/useCronitorHealth.ts
@@ -0,0 +1,37 @@
+import { useQuery } from '@tanstack/react-query';
+
+const CRONITOR_API_URL = 'https://cronitor.io/api/monitors/88kG4W?env=production&format=json';
+const POLL_INTERVAL = 60000; // 60 seconds
+
+interface CronitorResponse {
+ passing: boolean;
+ [key: string]: any; // Other fields we don't need
+}
+
+/**
+ * Hook to poll Cronitor API for health status
+ * Returns the monitor's passing status (true = healthy, false = down)
+ */
+export function useCronitorHealth() {
+ return useQuery({
+ queryKey: ['cronitor-health'],
+ queryFn: async (): Promise => {
+ const response = await fetch(CRONITOR_API_URL, {
+ method: 'GET',
+ headers: {
+ 'Accept': 'application/json',
+ },
+ });
+
+ if (!response.ok) {
+ throw new Error(`Cronitor API error: ${response.status}`);
+ }
+
+ return response.json();
+ },
+ refetchInterval: POLL_INTERVAL, // Auto-poll every 60 seconds
+ retry: 2, // Retry failed requests twice
+ staleTime: 30000, // Consider data stale after 30 seconds
+ gcTime: 5 * 60 * 1000, // Keep in cache for 5 minutes
+ });
+}