SkateSpots/src/components/PWAInstallPrompt.tsx
SkateSpots b2a6108eeb refactor: split non-component exports (react-refresh) into their own modules
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.
2026-09-26 23:29:42 +07:00

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;
}