Removes the last react-refresh disables by splitting exports instead: - contexts/language.ts: Language/Translations types + LanguageContext + useTranslation/useT (LanguageProvider stays a component-only file); 12 importers repointed. - lib/sortOptions.ts: calculatePopularity + calculateDistance (out of SortDropdown) - lib/trustFilter.ts: TrustFilterLevel + filterByTrustLevel (out of TrustFilter) - EventCountdown: deleted the unused useEventStatus hook - NewsletterPage: dropped the NEWSLETTER_ISSUES re-export shim - AGENTS.md updated (lint stays clean with no blanket disables) Gate: 101 tests, 0 errors/warnings, boot smoke green.
351 lines
15 KiB
TypeScript
351 lines
15 KiB
TypeScript
import { useEffect, useState, useRef, useCallback } from 'react';
|
|
import { useLocalStorage } from '@/hooks/useLocalStorage';
|
|
import { useTranslation } from '@/contexts/language';
|
|
import {
|
|
Sheet,
|
|
SheetContent,
|
|
SheetDescription,
|
|
SheetHeader,
|
|
SheetTitle,
|
|
} from '@/components/ui/sheet';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Download, X, Share, Plus, Smartphone } from 'lucide-react';
|
|
|
|
interface BeforeInstallPromptEvent extends Event {
|
|
prompt: () => Promise<void>;
|
|
userChoice: Promise<{ outcome: 'accepted' | 'dismissed' }>;
|
|
}
|
|
|
|
// Detect iOS
|
|
const isIOS = () => {
|
|
if (typeof navigator === 'undefined') return false;
|
|
return /iPad|iPhone|iPod/.test(navigator.userAgent) ||
|
|
(navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
|
|
};
|
|
|
|
// Detect if running in standalone mode (PWA)
|
|
const isStandalone = () => {
|
|
if (typeof window === 'undefined') return false;
|
|
return window.matchMedia('(display-mode: standalone)').matches ||
|
|
// iOS Safari specific check
|
|
('standalone' in window.navigator && (window.navigator as Navigator & { standalone: boolean }).standalone);
|
|
};
|
|
|
|
// Detect mobile/tablet
|
|
const isMobileOrTablet = () => {
|
|
if (typeof navigator === 'undefined') return false;
|
|
return /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent) ||
|
|
(navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1); // iPad
|
|
};
|
|
|
|
export function PWAInstallPrompt() {
|
|
const [deferredPrompt, setDeferredPrompt] = useState<BeforeInstallPromptEvent | null>(null);
|
|
const [showPrompt, setShowPrompt] = useState(false);
|
|
const [showManualPrompt, setShowManualPrompt] = useState(false);
|
|
const [dismissed, setDismissed] = useLocalStorage('pwa-install-dismissed', false);
|
|
const [installed, setInstalled] = useLocalStorage('pwa-installed', false);
|
|
const [isIOSDevice] = useState(isIOS());
|
|
const hasPromptFiredRef = useRef(false);
|
|
const { t } = useTranslation();
|
|
|
|
// Handler for beforeinstallprompt
|
|
const handleBeforeInstallPrompt = useCallback((e: Event) => {
|
|
e.preventDefault();
|
|
hasPromptFiredRef.current = true;
|
|
setDeferredPrompt(e as BeforeInstallPromptEvent);
|
|
}, []);
|
|
|
|
// Handler for app installed
|
|
const handleAppInstalled = useCallback(() => {
|
|
setInstalled(true);
|
|
setShowPrompt(false);
|
|
setShowManualPrompt(false);
|
|
setDeferredPrompt(null);
|
|
}, [setInstalled]);
|
|
|
|
useEffect(() => {
|
|
// Don't show if already dismissed or installed
|
|
if (dismissed || installed) return;
|
|
|
|
// Check if running in standalone mode (already installed)
|
|
if (isStandalone()) {
|
|
setInstalled(true);
|
|
return;
|
|
}
|
|
|
|
// Check if mobile/tablet - only show on mobile devices
|
|
if (!isMobileOrTablet()) return;
|
|
|
|
// Add event listeners
|
|
window.addEventListener('beforeinstallprompt', handleBeforeInstallPrompt);
|
|
window.addEventListener('appinstalled', handleAppInstalled);
|
|
|
|
// For iOS, we can't use beforeinstallprompt - show manual instructions after delay
|
|
if (isIOSDevice) {
|
|
const timer = setTimeout(() => {
|
|
setShowManualPrompt(true);
|
|
}, 5000);
|
|
return () => {
|
|
clearTimeout(timer);
|
|
window.removeEventListener('beforeinstallprompt', handleBeforeInstallPrompt);
|
|
window.removeEventListener('appinstalled', handleAppInstalled);
|
|
};
|
|
}
|
|
|
|
return () => {
|
|
window.removeEventListener('beforeinstallprompt', handleBeforeInstallPrompt);
|
|
window.removeEventListener('appinstalled', handleAppInstalled);
|
|
};
|
|
}, [dismissed, installed, setInstalled, isIOSDevice, handleBeforeInstallPrompt, handleAppInstalled]);
|
|
|
|
// Separate effect to handle showing prompts based on deferredPrompt state
|
|
useEffect(() => {
|
|
if (dismissed || installed) return;
|
|
if (!isMobileOrTablet() || isIOSDevice) return;
|
|
|
|
// If we have a deferred prompt, show native install after delay
|
|
if (deferredPrompt) {
|
|
const timer = setTimeout(() => {
|
|
setShowPrompt(true);
|
|
}, 5000);
|
|
return () => clearTimeout(timer);
|
|
}
|
|
|
|
// Fallback: If beforeinstallprompt doesn't fire within 7 seconds on Android, show manual prompt
|
|
const fallbackTimer = setTimeout(() => {
|
|
if (!hasPromptFiredRef.current && !deferredPrompt) {
|
|
setShowManualPrompt(true);
|
|
}
|
|
}, 7000);
|
|
|
|
return () => clearTimeout(fallbackTimer);
|
|
}, [deferredPrompt, dismissed, installed, isIOSDevice]);
|
|
|
|
const handleInstall = async () => {
|
|
if (!deferredPrompt) return;
|
|
|
|
// Show the install prompt
|
|
await deferredPrompt.prompt();
|
|
|
|
// Wait for the user's response
|
|
const { outcome } = await deferredPrompt.userChoice;
|
|
|
|
if (outcome === 'accepted') {
|
|
setInstalled(true);
|
|
}
|
|
|
|
// Clear the deferred prompt
|
|
setDeferredPrompt(null);
|
|
setShowPrompt(false);
|
|
};
|
|
|
|
const handleDismiss = () => {
|
|
setShowPrompt(false);
|
|
setShowManualPrompt(false);
|
|
setDismissed(true);
|
|
};
|
|
|
|
// Native install prompt (Android Chrome)
|
|
if (showPrompt && deferredPrompt) {
|
|
return (
|
|
<Sheet open={showPrompt} onOpenChange={setShowPrompt}>
|
|
<SheetContent
|
|
side="bottom"
|
|
className="rounded-t-2xl border-t-4 border-[#ff0040] bg-gradient-to-b from-[#ff0040]/10 to-black/95 backdrop-blur-lg"
|
|
onInteractOutside={(e) => e.preventDefault()}
|
|
>
|
|
<SheetHeader className="text-left">
|
|
<div className="flex items-start justify-between">
|
|
<div className="flex items-center gap-3">
|
|
<div className="flex-shrink-0 w-12 h-12 sm:w-14 sm:h-14 rounded-xl bg-gradient-to-br from-[#ff0040] to-[#cc0033] flex items-center justify-center shadow-lg shadow-[#ff0040]/30">
|
|
<Smartphone className="h-6 w-6 sm:h-7 sm:w-7 text-white" />
|
|
</div>
|
|
<div className="flex-1">
|
|
<SheetTitle className="text-lg sm:text-xl font-bold">
|
|
{t('pwa.installTitle')}
|
|
</SheetTitle>
|
|
<SheetDescription className="text-xs sm:text-sm text-gray-400">
|
|
{t('pwa.installSubtitle')}
|
|
</SheetDescription>
|
|
</div>
|
|
</div>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
onClick={handleDismiss}
|
|
className="ml-2 -mt-1 text-gray-500 hover:text-gray-300"
|
|
>
|
|
<X className="h-5 w-5" />
|
|
</Button>
|
|
</div>
|
|
</SheetHeader>
|
|
|
|
<div className="mt-4 sm:mt-6">
|
|
{/* Benefits list */}
|
|
<div className="grid grid-cols-3 gap-2 mb-5">
|
|
<div className="text-center p-2 rounded-lg bg-[#ff0040]/10 border border-[#ff0040]/30">
|
|
<p className="text-lg">⚡</p>
|
|
<p className="text-[10px] sm:text-xs text-gray-400">{t('pwa.benefits.fast')}</p>
|
|
</div>
|
|
<div className="text-center p-2 rounded-lg bg-[#ff0040]/10 border border-[#ff0040]/30">
|
|
<p className="text-lg">📴</p>
|
|
<p className="text-[10px] sm:text-xs text-gray-400">{t('pwa.benefits.offline')}</p>
|
|
</div>
|
|
<div className="text-center p-2 rounded-lg bg-[#ff0040]/10 border border-[#ff0040]/30">
|
|
<p className="text-lg">🛹</p>
|
|
<p className="text-[10px] sm:text-xs text-gray-400">{t('pwa.benefits.fullAccess')}</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-3">
|
|
<Button
|
|
onClick={handleInstall}
|
|
className="w-full h-12 sm:h-14 text-sm sm:text-base font-bold bg-[#ff0040] hover:bg-[#ff0040]/90 shadow-lg shadow-[#ff0040]/30"
|
|
size="lg"
|
|
>
|
|
<Download className="mr-2 h-5 w-5" />
|
|
{t('pwa.installApp')}
|
|
</Button>
|
|
|
|
<button
|
|
onClick={handleDismiss}
|
|
className="w-full text-center text-xs text-gray-500 hover:text-gray-400 py-2"
|
|
>
|
|
{t('pwa.maybeLater')}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</SheetContent>
|
|
</Sheet>
|
|
);
|
|
}
|
|
|
|
// Manual install instructions (iOS and fallback for other browsers)
|
|
if (showManualPrompt) {
|
|
return (
|
|
<Sheet open={showManualPrompt} onOpenChange={setShowManualPrompt}>
|
|
<SheetContent
|
|
side="bottom"
|
|
className="rounded-t-2xl border-t-4 border-[#ccff00] bg-gradient-to-b from-[#ccff00]/10 to-black/95 backdrop-blur-lg max-h-[85vh] overflow-y-auto"
|
|
onInteractOutside={(e) => e.preventDefault()}
|
|
>
|
|
<SheetHeader className="text-left">
|
|
<div className="flex items-start justify-between">
|
|
<div className="flex items-center gap-3">
|
|
<div className="flex-shrink-0 w-12 h-12 sm:w-14 sm:h-14 rounded-xl bg-gradient-to-br from-[#ccff00] to-[#99cc00] flex items-center justify-center shadow-lg shadow-[#ccff00]/30">
|
|
<Smartphone className="h-6 w-6 sm:h-7 sm:w-7 text-black" />
|
|
</div>
|
|
<div className="flex-1">
|
|
<SheetTitle className="text-lg sm:text-xl font-bold text-[#ccff00]">
|
|
{t('pwa.addToHomeScreen')}
|
|
</SheetTitle>
|
|
<SheetDescription className="text-xs sm:text-sm text-gray-400">
|
|
{isIOSDevice ? t('pwa.iosStep1Hint') : t('pwa.androidStep1Hint')}
|
|
</SheetDescription>
|
|
</div>
|
|
</div>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
onClick={handleDismiss}
|
|
className="ml-2 -mt-1 text-gray-500 hover:text-gray-300"
|
|
>
|
|
<X className="h-5 w-5" />
|
|
</Button>
|
|
</div>
|
|
</SheetHeader>
|
|
|
|
<div className="mt-4 sm:mt-6 space-y-4">
|
|
{isIOSDevice ? (
|
|
// iOS Instructions
|
|
<div className="space-y-3">
|
|
<div className="flex items-center gap-3 p-3 sm:p-4 rounded-xl bg-[#ccff00]/10 border border-[#ccff00]/30">
|
|
<div className="flex-shrink-0 w-9 h-9 sm:w-10 sm:h-10 rounded-full bg-[#ccff00] text-black flex items-center justify-center font-bold text-sm sm:text-base shadow-md">
|
|
1
|
|
</div>
|
|
<div className="flex-1 min-w-0">
|
|
<p className="text-sm sm:text-base font-medium text-white">{t('pwa.iosStep1')}</p>
|
|
<p className="text-xs sm:text-sm text-gray-400 flex items-center gap-1 mt-0.5">
|
|
<Share className="h-3 w-3 sm:h-4 sm:w-4" /> {t('pwa.iosStep1Hint')}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-3 p-3 sm:p-4 rounded-xl bg-[#ccff00]/10 border border-[#ccff00]/30">
|
|
<div className="flex-shrink-0 w-9 h-9 sm:w-10 sm:h-10 rounded-full bg-[#ccff00] text-black flex items-center justify-center font-bold text-sm sm:text-base shadow-md">
|
|
2
|
|
</div>
|
|
<div className="flex-1 min-w-0">
|
|
<p className="text-sm sm:text-base font-medium text-white">{t('pwa.iosStep2')}</p>
|
|
<p className="text-xs sm:text-sm text-gray-400 flex items-center gap-1 mt-0.5">
|
|
<Plus className="h-3 w-3 sm:h-4 sm:w-4" /> {t('pwa.iosStep2Hint')}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-3 p-3 sm:p-4 rounded-xl bg-[#ccff00]/10 border border-[#ccff00]/30">
|
|
<div className="flex-shrink-0 w-9 h-9 sm:w-10 sm:h-10 rounded-full bg-[#ccff00] text-black flex items-center justify-center font-bold text-sm sm:text-base shadow-md">
|
|
3
|
|
</div>
|
|
<div className="flex-1 min-w-0">
|
|
<p className="text-sm sm:text-base font-medium text-white">{t('pwa.iosStep3')}</p>
|
|
<p className="text-xs sm:text-sm text-gray-400 mt-0.5">{t('pwa.iosStep3Hint')}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
// Android/Other Instructions
|
|
<div className="space-y-3">
|
|
<div className="flex items-center gap-3 p-3 sm:p-4 rounded-xl bg-[#ccff00]/10 border border-[#ccff00]/30">
|
|
<div className="flex-shrink-0 w-9 h-9 sm:w-10 sm:h-10 rounded-full bg-[#ccff00] text-black flex items-center justify-center font-bold text-sm sm:text-base shadow-md">
|
|
1
|
|
</div>
|
|
<div className="flex-1 min-w-0">
|
|
<p className="text-sm sm:text-base font-medium text-white">{t('pwa.androidStep1')}</p>
|
|
<p className="text-xs sm:text-sm text-gray-400 mt-0.5">{t('pwa.androidStep1Hint')}</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-3 p-3 sm:p-4 rounded-xl bg-[#ccff00]/10 border border-[#ccff00]/30">
|
|
<div className="flex-shrink-0 w-9 h-9 sm:w-10 sm:h-10 rounded-full bg-[#ccff00] text-black flex items-center justify-center font-bold text-sm sm:text-base shadow-md">
|
|
2
|
|
</div>
|
|
<div className="flex-1 min-w-0">
|
|
<p className="text-sm sm:text-base font-medium text-white">{t('pwa.androidStep2')}</p>
|
|
<p className="text-xs sm:text-sm text-gray-400 mt-0.5">{t('pwa.androidStep2Hint')}</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-3 p-3 sm:p-4 rounded-xl bg-[#ccff00]/10 border border-[#ccff00]/30">
|
|
<div className="flex-shrink-0 w-9 h-9 sm:w-10 sm:h-10 rounded-full bg-[#ccff00] text-black flex items-center justify-center font-bold text-sm sm:text-base shadow-md">
|
|
3
|
|
</div>
|
|
<div className="flex-1 min-w-0">
|
|
<p className="text-sm sm:text-base font-medium text-white">{t('pwa.androidStep3')}</p>
|
|
<p className="text-xs sm:text-sm text-gray-400 mt-0.5">{t('pwa.androidStep3Hint')}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<Button
|
|
onClick={handleDismiss}
|
|
className="w-full h-12 text-sm sm:text-base font-semibold bg-[#ccff00] text-black hover:bg-[#ccff00]/90"
|
|
size="lg"
|
|
>
|
|
{t('pwa.gotIt')}
|
|
</Button>
|
|
</div>
|
|
|
|
<div className="mt-3 text-center">
|
|
<p className="text-xs text-muted-foreground">
|
|
{t('pwa.tagline')}
|
|
</p>
|
|
</div>
|
|
</SheetContent>
|
|
</Sheet>
|
|
);
|
|
}
|
|
|
|
return null;
|
|
}
|