feat: Implement PWA install prompt with platform-specific handling and dismissal persistence.
All checks were successful
Build and Deploy / build (push) Successful in 1m25s
All checks were successful
Build and Deploy / build (push) Successful in 1m25s
This commit is contained in:
1
src/client/dev-dist/registerSW.js
Normal file
1
src/client/dev-dist/registerSW.js
Normal file
@@ -0,0 +1 @@
|
||||
if('serviceWorker' in navigator) navigator.serviceWorker.register('/dev-sw.js?dev-sw', { scope: '/', type: 'classic' })
|
||||
92
src/client/dev-dist/sw.js
Normal file
92
src/client/dev-dist/sw.js
Normal file
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* Copyright 2018 Google Inc. All Rights Reserved.
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
// If the loader is already loaded, just stop.
|
||||
if (!self.define) {
|
||||
let registry = {};
|
||||
|
||||
// Used for `eval` and `importScripts` where we can't get script URL by other means.
|
||||
// In both cases, it's safe to use a global var because those functions are synchronous.
|
||||
let nextDefineUri;
|
||||
|
||||
const singleRequire = (uri, parentUri) => {
|
||||
uri = new URL(uri + ".js", parentUri).href;
|
||||
return registry[uri] || (
|
||||
|
||||
new Promise(resolve => {
|
||||
if ("document" in self) {
|
||||
const script = document.createElement("script");
|
||||
script.src = uri;
|
||||
script.onload = resolve;
|
||||
document.head.appendChild(script);
|
||||
} else {
|
||||
nextDefineUri = uri;
|
||||
importScripts(uri);
|
||||
resolve();
|
||||
}
|
||||
})
|
||||
|
||||
.then(() => {
|
||||
let promise = registry[uri];
|
||||
if (!promise) {
|
||||
throw new Error(`Module ${uri} didn’t register its module`);
|
||||
}
|
||||
return promise;
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
self.define = (depsNames, factory) => {
|
||||
const uri = nextDefineUri || ("document" in self ? document.currentScript.src : "") || location.href;
|
||||
if (registry[uri]) {
|
||||
// Module is already loading or loaded.
|
||||
return;
|
||||
}
|
||||
let exports = {};
|
||||
const require = depUri => singleRequire(depUri, uri);
|
||||
const specialDeps = {
|
||||
module: { uri },
|
||||
exports,
|
||||
require
|
||||
};
|
||||
registry[uri] = Promise.all(depsNames.map(
|
||||
depName => specialDeps[depName] || require(depName)
|
||||
)).then(deps => {
|
||||
factory(...deps);
|
||||
return exports;
|
||||
});
|
||||
};
|
||||
}
|
||||
define(['./workbox-5a5d9309'], (function (workbox) { 'use strict';
|
||||
|
||||
self.skipWaiting();
|
||||
workbox.clientsClaim();
|
||||
|
||||
/**
|
||||
* The precacheAndRoute() method efficiently caches and responds to
|
||||
* requests for URLs in the manifest.
|
||||
* See https://goo.gl/S9QRab
|
||||
*/
|
||||
workbox.precacheAndRoute([{
|
||||
"url": "registerSW.js",
|
||||
"revision": "3ca0b8505b4bec776b69afdba2768812"
|
||||
}, {
|
||||
"url": "index.html",
|
||||
"revision": "0.7uegorivig4"
|
||||
}], {});
|
||||
workbox.cleanupOutdatedCaches();
|
||||
workbox.registerRoute(new workbox.NavigationRoute(workbox.createHandlerBoundToURL("index.html"), {
|
||||
allowlist: [/^\/$/]
|
||||
}));
|
||||
|
||||
}));
|
||||
3395
src/client/dev-dist/workbox-5a5d9309.js
Normal file
3395
src/client/dev-dist/workbox-5a5d9309.js
Normal file
File diff suppressed because it is too large
Load Diff
@@ -8,6 +8,8 @@ import { Pack } from './services/PackGeneratorService';
|
||||
import { ToastProvider } from './components/Toast';
|
||||
import { GlobalContextMenu } from './components/GlobalContextMenu';
|
||||
|
||||
import { PWAInstallPrompt } from './components/PWAInstallPrompt';
|
||||
|
||||
export const App: React.FC = () => {
|
||||
const [activeTab, setActiveTab] = useState<'draft' | 'bracket' | 'lobby' | 'tester'>(() => {
|
||||
const saved = localStorage.getItem('activeTab');
|
||||
@@ -70,6 +72,7 @@ export const App: React.FC = () => {
|
||||
return (
|
||||
<ToastProvider>
|
||||
<GlobalContextMenu />
|
||||
<PWAInstallPrompt />
|
||||
<div className="h-screen flex flex-col bg-slate-900 text-slate-100 font-sans overflow-hidden">
|
||||
<header className="bg-slate-800 border-b border-slate-700 p-4 shrink-0 z-50 shadow-lg">
|
||||
<div className="max-w-7xl mx-auto flex flex-col md:flex-row justify-between items-center gap-4">
|
||||
|
||||
136
src/client/src/components/PWAInstallPrompt.tsx
Normal file
136
src/client/src/components/PWAInstallPrompt.tsx
Normal file
@@ -0,0 +1,136 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Download, X, Share } from 'lucide-react';
|
||||
|
||||
interface BeforeInstallPromptEvent extends Event {
|
||||
readonly platforms: string[];
|
||||
readonly userChoice: Promise<{
|
||||
outcome: 'accepted' | 'dismissed';
|
||||
platform: string;
|
||||
}>;
|
||||
prompt(): Promise<void>;
|
||||
}
|
||||
|
||||
export const PWAInstallPrompt: React.FC = () => {
|
||||
const [deferredPrompt, setDeferredPrompt] = useState<BeforeInstallPromptEvent | null>(null);
|
||||
const [showIOSPrompt, setShowIOSPrompt] = useState(false);
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// 0. Check persistence
|
||||
const isDismissed = localStorage.getItem('pwa_prompt_dismissed') === 'true';
|
||||
if (isDismissed) return;
|
||||
|
||||
// 1. Check if event was already captured globally
|
||||
const globalPrompt = (window as any).deferredInstallPrompt;
|
||||
if (globalPrompt) {
|
||||
setDeferredPrompt(globalPrompt);
|
||||
setIsVisible(true);
|
||||
}
|
||||
|
||||
// 2. Listen for future events (if not yet fired)
|
||||
const handler = (e: Event) => {
|
||||
e.preventDefault();
|
||||
setDeferredPrompt(e as BeforeInstallPromptEvent);
|
||||
setIsVisible(true);
|
||||
(window as any).deferredInstallPrompt = e; // Sync global just in case
|
||||
};
|
||||
|
||||
// 3. Listen for our custom event from main.tsx
|
||||
const customHandler = () => {
|
||||
const global = (window as any).deferredInstallPrompt;
|
||||
if (global) {
|
||||
setDeferredPrompt(global);
|
||||
setIsVisible(true);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('beforeinstallprompt', handler);
|
||||
window.addEventListener('deferred-prompt-ready', customHandler);
|
||||
|
||||
// 4. Check for iOS
|
||||
const userAgent = window.navigator.userAgent.toLowerCase();
|
||||
const isIOS = /iphone|ipad|ipod/.test(userAgent);
|
||||
const isStandalone = ('standalone' in window.navigator) && (window.navigator as any).standalone;
|
||||
|
||||
if (isIOS && !isStandalone) {
|
||||
// Delay slightly to start fresh
|
||||
setTimeout(() => setIsVisible(true), 1000);
|
||||
setShowIOSPrompt(true);
|
||||
}
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('beforeinstallprompt', handler);
|
||||
window.removeEventListener('deferred-prompt-ready', customHandler);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleDismiss = () => {
|
||||
setIsVisible(false);
|
||||
localStorage.setItem('pwa_prompt_dismissed', 'true');
|
||||
};
|
||||
|
||||
const handleInstallClick = async () => {
|
||||
if (!deferredPrompt) return;
|
||||
|
||||
setIsVisible(false);
|
||||
localStorage.setItem('pwa_prompt_dismissed', 'true'); // Don't ask again after user tries to install
|
||||
await deferredPrompt.prompt();
|
||||
const { outcome } = await deferredPrompt.userChoice;
|
||||
console.log(`User response to the install prompt: ${outcome}`);
|
||||
setDeferredPrompt(null);
|
||||
(window as any).deferredInstallPrompt = null;
|
||||
};
|
||||
|
||||
if (!isVisible) return null;
|
||||
|
||||
// iOS Specific Prompt
|
||||
if (showIOSPrompt) {
|
||||
return (
|
||||
<div className="fixed bottom-4 left-4 right-4 md:left-auto md:right-4 md:w-80 bg-slate-800 border border-purple-500 rounded-lg shadow-2xl p-4 z-50 flex flex-col gap-3 animate-in slide-in-from-bottom-5">
|
||||
<div className="flex justify-between items-start">
|
||||
<h3 className="font-bold text-slate-100">Install App</h3>
|
||||
<button onClick={handleDismiss} className="text-slate-400 hover:text-white">
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-sm text-slate-300">
|
||||
To install this app on your iPhone/iPad:
|
||||
</p>
|
||||
<ol className="text-sm text-slate-400 list-decimal list-inside space-y-1">
|
||||
<li className="flex items-center gap-2">Tap the <Share className="w-4 h-4 inline" /> Share button</li>
|
||||
<li>Scroll down and tap <span className="text-slate-200 font-semibold">Add to Home Screen</span></li>
|
||||
</ol>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Android / Desktop Prompt
|
||||
return (
|
||||
<div className="fixed bottom-4 left-4 right-4 md:left-auto md:right-4 md:w-96 bg-slate-800 border border-purple-500 rounded-lg shadow-2xl p-4 z-50 flex flex-col gap-3 animate-in slide-in-from-bottom-5">
|
||||
<div className="flex justify-between items-start">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="bg-purple-600/20 p-2 rounded-lg">
|
||||
<Download className="w-6 h-6 text-purple-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-bold text-slate-100">Install App</h3>
|
||||
<p className="text-xs text-slate-400">Add to Home Screen for better experience</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleDismiss}
|
||||
className="text-slate-400 hover:text-white transition-colors"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleInstallClick}
|
||||
className="w-full bg-purple-600 hover:bg-purple-500 text-white py-2 rounded-md font-bold text-sm transition-colors shadow-lg shadow-purple-900/20"
|
||||
>
|
||||
Install Now
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -2,11 +2,33 @@ import React from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { App } from './App';
|
||||
import './styles/main.css';
|
||||
import { registerSW } from 'virtual:pwa-register';
|
||||
|
||||
// Register Service Worker
|
||||
const updateSW = registerSW({
|
||||
onNeedRefresh() {
|
||||
// We could show a prompt here, but for now we'll just log or auto-reload
|
||||
console.log("New content available, auto-updating...");
|
||||
updateSW(true);
|
||||
},
|
||||
onOfflineReady() {
|
||||
console.log("App ready for offline use.");
|
||||
},
|
||||
});
|
||||
|
||||
// Capture install prompt early
|
||||
window.addEventListener('beforeinstallprompt', (e) => {
|
||||
e.preventDefault();
|
||||
// Store the event so it can be triggered later.
|
||||
// We attach it to valid window property or custom one
|
||||
(window as any).deferredInstallPrompt = e;
|
||||
// Dispatch a custom event to notify components if they are already mounted
|
||||
window.dispatchEvent(new Event('deferred-prompt-ready'));
|
||||
console.log("Captured beforeinstallprompt event");
|
||||
});
|
||||
|
||||
const rootElement = document.getElementById('root');
|
||||
|
||||
|
||||
|
||||
if (rootElement) {
|
||||
const root = createRoot(rootElement);
|
||||
root.render(
|
||||
|
||||
1
src/client/src/vite-env.d.ts
vendored
Normal file
1
src/client/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite-plugin-pwa/client" />
|
||||
@@ -9,6 +9,9 @@ export default defineConfig({
|
||||
VitePWA({
|
||||
registerType: 'autoUpdate',
|
||||
includeAssets: ['icon.svg'],
|
||||
devOptions: {
|
||||
enabled: true
|
||||
},
|
||||
manifest: {
|
||||
name: 'MTG Draft Maker',
|
||||
short_name: 'MTG Draft',
|
||||
|
||||
Reference in New Issue
Block a user