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:
@@ -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.
|
||||
- [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.
|
||||
- [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.
|
||||
|
||||
@@ -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
|
||||
33
docs/development/devlog/2025-12-18-005000_fix_pwa_prompt.md
Normal file
33
docs/development/devlog/2025-12-18-005000_fix_pwa_prompt.md
Normal 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
|
||||
|
||||
@@ -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
|
||||
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