All files / components UpdateNotification.tsx

81.41% Statements 92/113
90% Branches 18/20
100% Functions 4/4
81.41% Lines 92/113

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 1781x             1x                         1x 13x 13x 13x 13x   13x 11x   11x 11x 11x 11x 11x 11x 11x   11x 9x   9x   5x 9x   4x 4x 4x 9x     6x 6x   11x                                                 11x   1x 13x   13x   7x 7x 1x 1x     7x     7x     7x 4x 4x 7x   7x 7x 7x 7x 7x 13x   13x     1x   1x 1x   1x 1x 1x     1x       1x     1x         1x     1x 1x   13x 1x 1x 1x   13x 10x 10x   3x 3x 3x 3x 3x 3x 3x 3x 3x 3x 3x 3x 3x 3x 3x   3x 3x 3x 3x   3x 3x 3x 3x   3x  
'use client';
 
import { useState, useEffect, useCallback, useRef } from 'react';
import { RefreshCw, X } from 'lucide-react';
import { Button } from '@/components/ui/button';
 
// Check every 2 minutes for faster detection
const VERSION_CHECK_INTERVAL = 2 * 60 * 1000;
 
interface VersionInfo {
  version: string;
  buildTime: string;
}
 
interface VercelDeployment {
  uid: string;
  state: 'BUILDING' | 'ERROR' | 'INITIALIZING' | 'QUEUED' | 'READY' | 'CANCELED';
  createdAt: number;
}
 
export function UpdateNotification() {
  const [updateAvailable, setUpdateAvailable] = useState(false);
  const [dismissed, setDismissed] = useState(false);
  const initialVersionRef = useRef<string | null>(null);
  const lastDeploymentRef = useRef<string | null>(null);
 
  const checkForUpdates = useCallback(async () => {
    try {
      // Method 1: Check version.json (primary)
      const versionResponse = await fetch(`/version.json?t=${Date.now()}`, {
        cache: 'no-store',
        headers: {
          'Cache-Control': 'no-cache, no-store, must-revalidate',
          Pragma: 'no-cache',
        },
      });
 
      if (versionResponse.ok) {
        const data: VersionInfo = await versionResponse.json();
 
        if (initialVersionRef.current === null) {
          // First check - store the initial version
          initialVersionRef.current = data.version;
        } else if (data.version !== initialVersionRef.current) {
          // Version changed - update available
          setUpdateAvailable(true);
          return;
        }
      }
 
      // Method 2: Check Vercel deployment status via API (if available)
      const vercelProjectId = process.env.NEXT_PUBLIC_VERCEL_PROJECT_ID;
      const vercelTeamId = process.env.NEXT_PUBLIC_VERCEL_TEAM_ID;
 
      if (vercelProjectId) {
        try {
          // Check deployment via our own API endpoint to avoid CORS
          const deployResponse = await fetch(`/api/deployment-status?t=${Date.now()}`, {
            cache: 'no-store',
          });
 
          if (deployResponse.ok) {
            const deployData: { latestDeployment?: VercelDeployment } = await deployResponse.json();
 
            if (deployData.latestDeployment?.state === 'READY') {
              const deploymentId = deployData.latestDeployment.uid;
 
              if (lastDeploymentRef.current === null) {
                lastDeploymentRef.current = deploymentId;
              } else if (deploymentId !== lastDeploymentRef.current) {
                // New deployment is ready
                setUpdateAvailable(true);
              }
            }
          }
        } catch {
          // Vercel check failed, rely on version.json only
        }
      }
    } catch {
      // Silently fail if version check fails
    }
  }, []);
 
  useEffect(() => {
    // Check if user has dismissed this session
    const dismissedSession = sessionStorage.getItem('update-notification-dismissed');
    if (dismissedSession) {
      setDismissed(true);
    }
 
    // Initial check with slight delay to not block initial render
    const initialTimeout = setTimeout(checkForUpdates, 3000);
 
    // Set up periodic checks
    const intervalId = setInterval(checkForUpdates, VERSION_CHECK_INTERVAL);
 
    // Also check on window focus (user returns to tab)
    const handleFocus = () => {
      checkForUpdates();
    };
    window.addEventListener('focus', handleFocus);
 
    return () => {
      clearTimeout(initialTimeout);
      clearInterval(intervalId);
      window.removeEventListener('focus', handleFocus);
    };
  }, [checkForUpdates]);
 
  const handleRefresh = () => {
    // Hard refresh - clears cache and reloads
    // Using multiple methods for cross-browser compatibility
    if ('caches' in window) {
      // Clear service worker caches if any
      caches.keys().then((names) => {
        names.forEach((name) => {
          caches.delete(name);
        });
      });
    }
 
    // Clear any cached state
    sessionStorage.removeItem('update-notification-dismissed');
 
    // Build clean URL with cache-busting param
    // Handle hash fragments properly - they should come AFTER query params
    const url = new URL(window.location.href);
 
    // Remove any error fragments (from Supabase errors)
    if (url.hash.includes('error=')) {
      url.hash = '';
    }
 
    // Add cache-busting param
    url.searchParams.set('_refresh', Date.now().toString());
 
    // Navigate to clean cache-busted URL
    window.location.href = url.toString();
  };
 
  const handleDismiss = () => {
    setDismissed(true);
    sessionStorage.setItem('update-notification-dismissed', 'true');
  };
 
  if (!updateAvailable || dismissed) {
    return null;
  }
 
  return (
    <div className="fixed bottom-4 right-4 z-50 duration-300 animate-in fade-in slide-in-from-bottom-4">
      <div className="flex items-center gap-3 rounded-lg border bg-background/95 p-4 shadow-lg backdrop-blur supports-[backdrop-filter]:bg-background/80">
        <div className="h-2 w-2 animate-pulse rounded-full bg-green-500" />
        <div className="flex-1">
          <p className="text-sm font-medium">New version available</p>
          <p className="text-xs text-muted-foreground">Click refresh to get the latest updates</p>
        </div>
        <div className="flex items-center gap-2">
          <Button
            size="sm"
            variant="ghost"
            className="h-8 w-8 p-0"
            onClick={handleDismiss}
            aria-label="Dismiss"
          >
            <X className="h-4 w-4" />
          </Button>
          <Button size="sm" onClick={handleRefresh} className="gap-2">
            <RefreshCw className="h-3 w-3" />
            Refresh
          </Button>
        </div>
      </div>
    </div>
  );
}