- Wrap next.config.ts with @serwist/next (webpack mode, disabled in dev) - Service worker: NetworkOnly for /api/*, offline fallback → /~offline - Web app manifest via Next.js metadata API (app/manifest.ts) - PNG icon set generated with sharp (192, 512, maskable-512, apple-180) - iOS meta tags: appleWebApp, themeColor viewport export - Middleware: pwaAssets early-return so /sw.js never gets a 302→login - Offline fallback page at /~offline (static, no auth dependency) - InstallPrompt component: beforeinstallprompt (Android) + iOS Share sheet instructions - Logout (menu/page.tsx): purge all SW caches on signout (shared-device safety) - Fix invite/[token]/page.tsx params type for Next.js 16 (use(params)) - Build script: next build --webpack (Serwist requires webpack, not Turbopack) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
115 lines
3.8 KiB
TypeScript
115 lines
3.8 KiB
TypeScript
"use client";
|
||
|
||
import { useEffect, useState } from "react";
|
||
|
||
// Extend the BeforeInstallPromptEvent type (not in standard lib)
|
||
interface BeforeInstallPromptEvent extends Event {
|
||
prompt(): Promise<void>;
|
||
readonly userChoice: Promise<{ outcome: "accepted" | "dismissed" }>;
|
||
}
|
||
|
||
function IOSInstallInstructions({ onDismiss }: { onDismiss: () => void }) {
|
||
return (
|
||
<div className="fixed bottom-4 left-4 right-4 z-50 bg-white border border-rose-100 rounded-2xl shadow-lg p-4">
|
||
<div className="flex justify-between items-start mb-2">
|
||
<div className="flex items-center gap-2">
|
||
<span className="text-2xl">🌸</span>
|
||
<span className="font-semibold text-gray-900">Install Tia</span>
|
||
</div>
|
||
<button
|
||
onClick={onDismiss}
|
||
className="text-gray-400 text-xl leading-none p-1"
|
||
aria-label="Dismiss"
|
||
>
|
||
✕
|
||
</button>
|
||
</div>
|
||
<p className="text-sm text-gray-600 mb-2">
|
||
Add Tia to your home screen for the best experience.
|
||
</p>
|
||
<div className="flex items-center gap-2 text-sm text-gray-700">
|
||
<span>Tap</span>
|
||
<span className="inline-flex items-center justify-center w-7 h-7 bg-gray-100 rounded-md text-base">⬆️</span>
|
||
<span>then</span>
|
||
<span className="font-medium">"Add to Home Screen"</span>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const DISMISSED_KEY = "tia_install_prompt_dismissed";
|
||
|
||
export function InstallPrompt() {
|
||
const [deferred, setDeferred] = useState<BeforeInstallPromptEvent | null>(null);
|
||
const [isIOS, setIsIOS] = useState(false);
|
||
const [dismissed, setDismissed] = useState(true); // start hidden to avoid flash
|
||
|
||
useEffect(() => {
|
||
if (typeof window === "undefined") return;
|
||
|
||
const alreadyDismissed = localStorage.getItem(DISMISSED_KEY) === "1";
|
||
if (alreadyDismissed) return;
|
||
|
||
const standalone = window.matchMedia("(display-mode: standalone)").matches;
|
||
if (standalone) return; // already installed
|
||
|
||
// iOS Safari: no beforeinstallprompt, needs manual instructions
|
||
const ios = /iphone|ipad|ipod/i.test(navigator.userAgent);
|
||
const isSafari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent);
|
||
if (ios && isSafari) {
|
||
setIsIOS(true);
|
||
setDismissed(false);
|
||
}
|
||
|
||
// Android / Chrome: capture the deferred install event
|
||
const handler = (e: Event) => {
|
||
e.preventDefault();
|
||
setDeferred(e as BeforeInstallPromptEvent);
|
||
setDismissed(false);
|
||
};
|
||
window.addEventListener("beforeinstallprompt", handler);
|
||
return () => window.removeEventListener("beforeinstallprompt", handler);
|
||
}, []);
|
||
|
||
const handleDismiss = () => {
|
||
setDismissed(true);
|
||
localStorage.setItem(DISMISSED_KEY, "1");
|
||
};
|
||
|
||
const handleInstall = async () => {
|
||
if (!deferred) return;
|
||
await deferred.prompt();
|
||
const { outcome } = await deferred.userChoice;
|
||
if (outcome === "accepted") {
|
||
setDeferred(null);
|
||
setDismissed(true);
|
||
}
|
||
};
|
||
|
||
if (dismissed) return null;
|
||
|
||
if (deferred) {
|
||
return (
|
||
<div className="fixed bottom-4 left-4 right-4 z-50 bg-white border border-rose-100 rounded-2xl shadow-lg p-4 flex items-center gap-3">
|
||
<span className="text-2xl">🌸</span>
|
||
<div className="flex-1">
|
||
<p className="font-semibold text-gray-900 text-sm">Install Tia</p>
|
||
<p className="text-xs text-gray-500">Add to home screen for quick access</p>
|
||
</div>
|
||
<button onClick={handleDismiss} className="text-gray-400 p-1 text-lg" aria-label="Dismiss">✕</button>
|
||
<button
|
||
onClick={handleInstall}
|
||
className="px-4 py-2 bg-rose-400 text-white rounded-xl text-sm font-semibold"
|
||
>
|
||
Install
|
||
</button>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
if (isIOS) {
|
||
return <IOSInstallInstructions onDismiss={handleDismiss} />;
|
||
}
|
||
|
||
return null;
|
||
}
|