From cb0dee9469047c6f4df0905fb2365c528465c4f0 Mon Sep 17 00:00:00 2001 From: kuekhaoyang Date: Fri, 2 Jan 2026 14:04:23 +0800 Subject: [PATCH] feat: Implement PWA installation prompt and banner. --- app/layout.tsx | 2 + components/PWAInstallBanner.tsx | 83 +++++++++++++++++++++++++++++++ lib/hooks/usePWAInstall.ts | 86 +++++++++++++++++++++++++++++++++ 3 files changed, 171 insertions(+) create mode 100644 components/PWAInstallBanner.tsx create mode 100644 lib/hooks/usePWAInstall.ts diff --git a/app/layout.tsx b/app/layout.tsx index 8114efc..58fc7b1 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -5,6 +5,7 @@ import { ThemeProvider } from "@/components/ThemeProvider"; import { Analytics } from "@vercel/analytics/react"; import { ServiceWorkerRegister } from "@/components/ServiceWorkerRegister"; import { PasswordGate } from "@/components/PasswordGate"; +import { PWAInstallBanner } from "@/components/PWAInstallBanner"; import { siteConfig } from "@/lib/config/site-config"; @@ -46,6 +47,7 @@ export default function RootLayout({ suppressHydrationWarning > + {children} diff --git a/components/PWAInstallBanner.tsx b/components/PWAInstallBanner.tsx new file mode 100644 index 0000000..57cb8f6 --- /dev/null +++ b/components/PWAInstallBanner.tsx @@ -0,0 +1,83 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import { usePWAInstall } from '@/lib/hooks/usePWAInstall'; +import { X, Download, Share } from 'lucide-react'; +import { Button } from '@/components/ui/Button'; + +export function PWAInstallBanner() { + const { isInstallable, isIOS, handleInstallClick } = usePWAInstall(); + const [isVisible, setIsVisible] = useState(false); + const [isDismissed, setIsDismissed] = useState(false); + + useEffect(() => { + // Show banner after a short delay to not be too intrusive + if ((isInstallable || isIOS) && !isDismissed) { + const timer = setTimeout(() => setIsVisible(true), 2000); + return () => clearTimeout(timer); + } else { + setIsVisible(false); + } + }, [isInstallable, isIOS, isDismissed]); + + const handleDismiss = () => { + setIsVisible(false); + setIsDismissed(true); + // Optionally save to localStorage to not show again for a while + localStorage.setItem('pwa-banner-dismissed', Date.now().toString()); + }; + + useEffect(() => { + const dismissedAt = localStorage.getItem('pwa-banner-dismissed'); + if (dismissedAt) { + const oneDay = 24 * 60 * 60 * 1000; + if (Date.now() - parseInt(dismissedAt) < oneDay) { + setIsDismissed(true); + } + } + }, []); + + if (!isVisible) return null; + + return ( +
+
+
+
+ KVideo +
+
+

安装 KVideo

+

+ {isIOS ? '点击浏览器分享按钮,选择“添加到主屏幕”' : '安装到桌面,体验更流畅'} +

+
+
+ +
+ {!isIOS && ( + + )} + {isIOS && ( +
+ +
+ )} + +
+
+
+ ); +} diff --git a/lib/hooks/usePWAInstall.ts b/lib/hooks/usePWAInstall.ts new file mode 100644 index 0000000..2e8919b --- /dev/null +++ b/lib/hooks/usePWAInstall.ts @@ -0,0 +1,86 @@ +'use client'; + +import { useState, useEffect } from 'react'; + +interface BeforeInstallPromptEvent extends Event { + readonly platforms: string[]; + readonly userChoice: Promise<{ + outcome: 'accepted' | 'dismissed'; + platform: string; + }>; + prompt(): Promise; +} + +export function usePWAInstall() { + const [deferredPrompt, setDeferredPrompt] = useState(null); + const [isInstallable, setIsInstallable] = useState(false); + const [isInstalled, setIsInstalled] = useState(false); + const [isIOS, setIsIOS] = useState(false); + const [isStandalone, setIsStandalone] = useState(false); + + useEffect(() => { + // Check if app is already installed/standalone + const checkStandalone = () => { + const isStandaloneMode = window.matchMedia('(display-mode: standalone)').matches + || (window.navigator as any).standalone + || document.referrer.includes('android-app://'); + setIsStandalone(isStandaloneMode); + setIsInstalled(isStandaloneMode); + }; + + checkStandalone(); + + // Check if iOS + const checkIOS = () => { + const ua = window.navigator.userAgent.toLowerCase(); + const isIOSDevice = /iphone|ipad|ipod/.test(ua); + setIsIOS(isIOSDevice); + }; + + checkIOS(); + + const handleBeforeInstallPrompt = (e: Event) => { + // Prevent the mini-infobar from appearing on mobile + e.preventDefault(); + // Stash the event so it can be triggered later. + setDeferredPrompt(e as BeforeInstallPromptEvent); + setIsInstallable(true); + }; + + const handleAppInstalled = () => { + setIsInstalled(true); + setIsInstallable(false); + setDeferredPrompt(null); + }; + + window.addEventListener('beforeinstallprompt', handleBeforeInstallPrompt); + window.addEventListener('appinstalled', handleAppInstalled); + + return () => { + window.removeEventListener('beforeinstallprompt', handleBeforeInstallPrompt); + window.removeEventListener('appinstalled', handleAppInstalled); + }; + }, []); + + const handleInstallClick = async () => { + if (!deferredPrompt) return; + + // Show the install prompt + deferredPrompt.prompt(); + + // Wait for the user to respond to the prompt + const { outcome } = await deferredPrompt.userChoice; + + if (outcome === 'accepted') { + setIsInstallable(false); + setDeferredPrompt(null); + } + }; + + return { + isInstallable: isInstallable && !isInstalled, + isIOS: isIOS && !isStandalone, + isInstalled, + handleInstallClick, + }; +}