feat: Implement PWA install prompt with platform-specific handling and dismissal persistence.
All checks were successful
Build and Deploy / build (push) Successful in 1m25s

This commit is contained in:
2025-12-18 00:55:45 +01:00
parent 60db2a91df
commit c8d2871126
12 changed files with 3734 additions and 2 deletions

View 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
View 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} didnt 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: [/^\/$/]
}));
}));

File diff suppressed because it is too large Load Diff

View File

@@ -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">

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

View File

@@ -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
View File

@@ -0,0 +1 @@
/// <reference types="vite-plugin-pwa/client" />

View File

@@ -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',