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

@@ -90,3 +90,6 @@
- [Customizable Deck Builder Layout](./devlog/2025-12-17-170000_customizable_deck_builder.md): Completed. Implemented switchable Vertical (Side-by-Side) and Horizontal (Top-Bottom) layouts, with an integrated, improved Land Station. - [Customizable Deck Builder Layout](./devlog/2025-12-17-170000_customizable_deck_builder.md): Completed. Implemented switchable Vertical (Side-by-Side) and Horizontal (Top-Bottom) layouts, with an integrated, improved Land Station.
- [Draft View Layout Selection](./devlog/2025-12-17-185000_draft_view_layout.md): Completed. Implemented Vertical/Horizontal layout selection for Draft View to match Deck Builder, optimizing screen space and preventing overlap. - [Draft View Layout Selection](./devlog/2025-12-17-185000_draft_view_layout.md): Completed. Implemented Vertical/Horizontal layout selection for Draft View to match Deck Builder, optimizing screen space and preventing overlap.
- [Fix Cube Sidebar Scrolling](./devlog/2025-12-18-004109_fix_cube_sidebar_scrolling.md): Completed. Adjusted sidebar max-height to ensure autonomous scrolling and button accessibility on tablet screens. - [Fix Cube Sidebar Scrolling](./devlog/2025-12-18-004109_fix_cube_sidebar_scrolling.md): Completed. Adjusted sidebar max-height to ensure autonomous scrolling and button accessibility on tablet screens.
- [PWA Install Prompt](./devlog/2025-12-18-004600_pwa_install_prompt.md): Completed. Implemented `PWAInstallPrompt` component in `App.tsx` and enabled PWA dev options.
- [Fix PWA Install Prompt](./devlog/2025-12-18-005000_fix_pwa_prompt.md): Completed. Implemented global event capture, iOS detection, and explicit service worker registration to ensure install prompt appears.
- [Persist PWA Dismissal](./devlog/2025-12-18-005300_persist_pwa_dismissal.md): Completed. Implemented logic to remember user's choice to dismiss or install the PWA, preventing repeated prompts.

View File

@@ -0,0 +1,23 @@
# PWA Install Prompt Implementation Plan
## Objective
Implement a user interface that prompts the user to install the application as a PWA on supported devices (primarily Android/Chrome).
## Tasks
1. Create `src/client/src/components/PWAInstallPrompt.tsx` that:
- Listens for `beforeinstallprompt` event.
- Displays a custom UI (toast/banner) when the event is captured.
- Calls `prompt()` on the event object when the user clicks "Install".
- Handles the user's choice.
2. Integrate `PWAInstallPrompt` into `App.tsx`.
3. Verify `vite.config.ts` PWA settings (already done, looks good).
## Implementation Details
- The component will use a fixed position styling (bottom right/center) to be noticeable but not blocking.
- It will use existing design system (Tailwind).
## Status
- [x] Create Component
- [x] Integrate into App
- [x] Update Config
- **Completed**: 2025-12-18

View File

@@ -0,0 +1,33 @@
# Fix PWA Install Prompt Implementation Plan
## Objective
Ensure the PWA install prompt appears reliably on mobile devices, addressing potential issues with event timing, iOS compatibility, and explicit Service Worker registration.
## Root Causes to Address
1. **Late Event Listener**: The `beforeinstallprompt` event might fire before the React component mounts. We need to capture it globally as early as possible.
2. **Missing Service Worker Registration**: While `vite-plugin-pwa` can auto-inject, explicit registration in `main.tsx` ensures it's active and handles updates.
3. **iOS Compatibility**: iOS does not support `beforeinstallprompt`. We must detect iOS and show specific "Share -> Add to Home Screen" instructions.
4. **Secure Context**: PWA features require HTTPS. While we can't force this on the user's network, we can ensure the app behaves best-effort and maybe warn if needed (skipped for now to avoid noise, focusing on logical fixes).
## Tasks
1. **Update `src/client/src/main.tsx`**:
- Import `registerSW` from `virtual:pwa-register`.
- Add a global `window` event listener for `beforeinstallprompt` immediately upon script execution to capture the event.
- Expose the captured event on `window` (or a global store) so the React component can consume it.
2. **Update `src/client/src/components/PWAInstallPrompt.tsx`**:
- Check `window.deferredInstallPrompt` (or similar) on mount.
- Add User Agent detection for iOS (iPhone/iPad/iPod).
- If iOS, display a custom "Add to Home Screen" instruction banner.
- If Android/Desktop, use the captured prompt.
3. **Docs**: Update devlog.
## Technical Details
- **Global Property**: `window.deferredInstallPrompt`
- **iOS Detection**: Regex on `navigator.userAgent` looking for `iPhone|iPad|iPod` (and not `MSStream`).
## Status
- [x] Register SW explicitly in main.tsx
- [x] Capture `beforeinstallprompt` globally
- [x] Add iOS specific UI
- **Completed**: 2025-12-18

View File

@@ -0,0 +1,20 @@
# Persist PWA Prompt Dismissal
## Objective
Ensure the PWA install prompt honors the user's previous interactions. If the user dismisses the prompt (clicks X) or initiates the install flow, the prompt should not appear again in subsequent sessions.
## Implementation Details
1. **Storage**: Use `localStorage` key `pwa_prompt_dismissed` (value: 'true').
2. **Logic Update** in `PWAInstallPrompt.tsx`:
- On mount: Check if `localStorage.getItem('pwa_prompt_dismissed') === 'true'`. If so, return `null` immediately.
- On Dismiss (X click): Set `localStorage.setItem('pwa_prompt_dismissed', 'true')` and hide UI.
- On Install Click: Set `localStorage.setItem('pwa_prompt_dismissed', 'true')` immediately. Even if they cancel the native dialog, we respect their choice to have interacted with it once. (Or should we? The user said "after a use choice". I will assume entering the flow counts).
## Refinements
- We might want to allow re-prompting after a long time (e.g., storing a timestamp), but the request is simple: "do not show... after a use choice". I will stick to simple boolean persistence for now.
## Status
- [x] Add logic to check/set `pwa_prompt_dismissed`
- [x] Update dismissal (X button) logic
- [x] Update install logic
- **Completed**: 2025-12-18

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 { ToastProvider } from './components/Toast';
import { GlobalContextMenu } from './components/GlobalContextMenu'; import { GlobalContextMenu } from './components/GlobalContextMenu';
import { PWAInstallPrompt } from './components/PWAInstallPrompt';
export const App: React.FC = () => { export const App: React.FC = () => {
const [activeTab, setActiveTab] = useState<'draft' | 'bracket' | 'lobby' | 'tester'>(() => { const [activeTab, setActiveTab] = useState<'draft' | 'bracket' | 'lobby' | 'tester'>(() => {
const saved = localStorage.getItem('activeTab'); const saved = localStorage.getItem('activeTab');
@@ -70,6 +72,7 @@ export const App: React.FC = () => {
return ( return (
<ToastProvider> <ToastProvider>
<GlobalContextMenu /> <GlobalContextMenu />
<PWAInstallPrompt />
<div className="h-screen flex flex-col bg-slate-900 text-slate-100 font-sans overflow-hidden"> <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"> <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"> <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 { createRoot } from 'react-dom/client';
import { App } from './App'; import { App } from './App';
import './styles/main.css'; 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'); const rootElement = document.getElementById('root');
if (rootElement) { if (rootElement) {
const root = createRoot(rootElement); const root = createRoot(rootElement);
root.render( 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({ VitePWA({
registerType: 'autoUpdate', registerType: 'autoUpdate',
includeAssets: ['icon.svg'], includeAssets: ['icon.svg'],
devOptions: {
enabled: true
},
manifest: { manifest: {
name: 'MTG Draft Maker', name: 'MTG Draft Maker',
short_name: 'MTG Draft', short_name: 'MTG Draft',