SkateSpots/src/components/PWAInstallPrompt.tsx
shakespeare.diy e29d35f1e4 fix: restore full skate-spots source tree from misplaced src/src/ copy
The entire skate-spots codebase was accidentally copied into src/src/ and
public/public/ instead of src/ and public/ directly. This left the real
src/ directory as blank MKStack template stubs, explaining why the landing
page showed the default template instead of SkateSpots.

Changes made:
- Copied all files from src/src/ up to src/ (App.tsx, AppRouter.tsx, main.tsx,
  all pages, components, hooks, contexts, lib, i18n, test)
- Copied all files from public/public/ up to public/ (images, manifest, etc.)
- Deleted src/src/ and public/public/ nested directories
- Removed HelloWorld.tsx template stub from pages/
- Retained the previously fixed index.css (Tailwind v3 directives)

Full skate-spots feature set is now active:
- All 30+ routes (map, events, spots, coaches, marketplace, wallet, etc.)
- NWCProvider, LanguageProvider, PWAInstallPrompt, AmberCallbackHandler
- Skate community relays (goskatespots.nostr1.com, wotr.relatr.xyz, etc.)
- Outfit Variable font, Amber pre-check in main.tsx
- All hooks (useSkateSpots, useCoaches, useZaps, etc.)
- i18n support (en/es)

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
2026-06-15 22:18:28 +07:00

351 lines
15 KiB
TypeScript

import { useEffect, useState, useRef, useCallback } from 'react';
import { useLocalStorage } from '@/hooks/useLocalStorage';
import { useTranslation } from '@/contexts/LanguageContext';
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;
}