feat(pwa): add one-time install prompt for mobile devices
This commit is contained in:
@@ -24,6 +24,8 @@ export const viewport: Viewport = {
|
|||||||
maximumScale: 1,
|
maximumScale: 1,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
import InstallPrompt from "@/components/InstallPrompt";
|
||||||
|
|
||||||
export default function RootLayout({
|
export default function RootLayout({
|
||||||
children,
|
children,
|
||||||
}: Readonly<{
|
}: Readonly<{
|
||||||
@@ -33,6 +35,7 @@ export default function RootLayout({
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<body className={`${geistSans.variable} ${geistMono.variable}`}>
|
<body className={`${geistSans.variable} ${geistMono.variable}`}>
|
||||||
{children}
|
{children}
|
||||||
|
<InstallPrompt />
|
||||||
<footer className="app-footer">
|
<footer className="app-footer">
|
||||||
<p>
|
<p>
|
||||||
Vibe coded with ☕ and 🍺 by{' '}
|
Vibe coded with ☕ and 🍺 by{' '}
|
||||||
|
|||||||
132
components/InstallPrompt.tsx
Normal file
132
components/InstallPrompt.tsx
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
|
||||||
|
export default function InstallPrompt() {
|
||||||
|
const [isIOS, setIsIOS] = useState(false);
|
||||||
|
const [isStandalone, setIsStandalone] = useState(false);
|
||||||
|
const [showPrompt, setShowPrompt] = useState(false);
|
||||||
|
const [deferredPrompt, setDeferredPrompt] = useState<any>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Check if already in standalone mode
|
||||||
|
const isStandaloneMode = window.matchMedia('(display-mode: standalone)').matches || (window.navigator as any).standalone;
|
||||||
|
setIsStandalone(isStandaloneMode);
|
||||||
|
|
||||||
|
// Check if iOS
|
||||||
|
const userAgent = window.navigator.userAgent.toLowerCase();
|
||||||
|
const isIosDevice = /iphone|ipad|ipod/.test(userAgent);
|
||||||
|
setIsIOS(isIosDevice);
|
||||||
|
|
||||||
|
// Check if already dismissed
|
||||||
|
const isDismissed = localStorage.getItem('installPromptDismissed');
|
||||||
|
|
||||||
|
if (!isStandaloneMode && !isDismissed) {
|
||||||
|
if (isIosDevice) {
|
||||||
|
// Show prompt for iOS immediately if not dismissed
|
||||||
|
setShowPrompt(true);
|
||||||
|
} else {
|
||||||
|
// For Android/Desktop, wait for beforeinstallprompt
|
||||||
|
const handleBeforeInstallPrompt = (e: Event) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setDeferredPrompt(e);
|
||||||
|
setShowPrompt(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('beforeinstallprompt', handleBeforeInstallPrompt);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('beforeinstallprompt', handleBeforeInstallPrompt);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleInstallClick = async () => {
|
||||||
|
if (deferredPrompt) {
|
||||||
|
deferredPrompt.prompt();
|
||||||
|
const { outcome } = await deferredPrompt.userChoice;
|
||||||
|
if (outcome === 'accepted') {
|
||||||
|
setDeferredPrompt(null);
|
||||||
|
setShowPrompt(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDismiss = () => {
|
||||||
|
setShowPrompt(false);
|
||||||
|
localStorage.setItem('installPromptDismissed', 'true');
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!showPrompt) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
position: 'fixed',
|
||||||
|
bottom: '20px',
|
||||||
|
left: '20px',
|
||||||
|
right: '20px',
|
||||||
|
background: 'rgba(255, 255, 255, 0.95)',
|
||||||
|
backdropFilter: 'blur(10px)',
|
||||||
|
padding: '1rem',
|
||||||
|
borderRadius: '1rem',
|
||||||
|
boxShadow: '0 10px 25px rgba(0,0,0,0.2)',
|
||||||
|
zIndex: 1000,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: '0.5rem',
|
||||||
|
border: '1px solid rgba(0,0,0,0.1)',
|
||||||
|
animation: 'slideUp 0.5s ease-out'
|
||||||
|
}}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'start' }}>
|
||||||
|
<div>
|
||||||
|
<h3 style={{ fontWeight: 'bold', fontSize: '1rem', marginBottom: '0.25rem' }}>Install Hördle App</h3>
|
||||||
|
<p style={{ fontSize: '0.875rem', color: '#666' }}>
|
||||||
|
Install the app for a better experience and quick access!
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={handleDismiss}
|
||||||
|
style={{
|
||||||
|
background: 'none',
|
||||||
|
border: 'none',
|
||||||
|
fontSize: '1.25rem',
|
||||||
|
cursor: 'pointer',
|
||||||
|
padding: '0.25rem',
|
||||||
|
color: '#999'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isIOS ? (
|
||||||
|
<div style={{ fontSize: '0.875rem', background: '#f3f4f6', padding: '0.75rem', borderRadius: '0.5rem', marginTop: '0.5rem' }}>
|
||||||
|
Tap <span style={{ fontSize: '1.2rem' }}>share</span> then "Add to Home Screen" <span style={{ fontSize: '1.2rem' }}>+</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={handleInstallClick}
|
||||||
|
style={{
|
||||||
|
background: '#4f46e5',
|
||||||
|
color: 'white',
|
||||||
|
border: 'none',
|
||||||
|
padding: '0.75rem',
|
||||||
|
borderRadius: '0.5rem',
|
||||||
|
fontWeight: 'bold',
|
||||||
|
cursor: 'pointer',
|
||||||
|
marginTop: '0.5rem'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Install App
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<style jsx>{`
|
||||||
|
@keyframes slideUp {
|
||||||
|
from { transform: translateY(100%); opacity: 0; }
|
||||||
|
to { transform: translateY(0); opacity: 1; }
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user