Phase 8
This commit is contained in:
82 files changed
+4903
-287
No files matched your search
@@ -1,79 +1,37 @@
|
||||
"use client";
|
||||
|
||||
import { Download } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
interface BeforeInstallPromptEvent extends Event {
|
||||
prompt: () => Promise<void>;
|
||||
userChoice: Promise<{ outcome: "accepted" | "dismissed" }>;
|
||||
}
|
||||
|
||||
function isStandalone(): boolean {
|
||||
if (typeof window === "undefined") return false;
|
||||
return (
|
||||
window.matchMedia("(display-mode: standalone)").matches ||
|
||||
// iOS Safari's own (non-standard, but the only signal it exposes).
|
||||
(window.navigator as Navigator & { standalone?: boolean }).standalone === true
|
||||
);
|
||||
}
|
||||
|
||||
function isIos(): boolean {
|
||||
if (typeof navigator === "undefined") return false;
|
||||
return /iphone|ipad|ipod/i.test(navigator.userAgent);
|
||||
}
|
||||
import { useInstallPrompt } from "../hooks/use-install-prompt";
|
||||
|
||||
/**
|
||||
* Chrome/Edge/Android fire `beforeinstallprompt` when the manifest +
|
||||
* service worker criteria are met — we capture and defer it (the spec
|
||||
* requires preventDefault() to control exactly when the native prompt
|
||||
* shows) and trigger it from this button instead of an unsolicited popup.
|
||||
* iOS Safari never fires that event at all, so it gets manual
|
||||
* "Add to Home Screen" instructions instead — there's no programmatic
|
||||
* install API on that platform.
|
||||
* service worker criteria are met — captured and triggered from this
|
||||
* button instead of an unsolicited popup (see use-install-prompt.ts,
|
||||
* shared with WelcomeInstallCard so there's exactly one PWA install
|
||||
* implementation, not two). iOS Safari never fires that event at all, so
|
||||
* it gets manual "Add to Home Screen" instructions instead. `businessName`
|
||||
* comes from the caller — every Settings page already fetches branding
|
||||
* server-side, so this client component doesn't need to.
|
||||
*/
|
||||
export function InstallAppButton() {
|
||||
const [deferredPrompt, setDeferredPrompt] = useState<BeforeInstallPromptEvent | null>(null);
|
||||
const [installed, setInstalled] = useState(() => isStandalone());
|
||||
export function InstallAppButton({ businessName }: { businessName: string }) {
|
||||
const { installed, ios, canPromptNatively, promptInstall } = useInstallPrompt();
|
||||
const [showIosInstructions, setShowIosInstructions] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const onBeforeInstallPrompt = (event: Event) => {
|
||||
event.preventDefault();
|
||||
setDeferredPrompt(event as BeforeInstallPromptEvent);
|
||||
};
|
||||
const onInstalled = () => {
|
||||
setInstalled(true);
|
||||
setDeferredPrompt(null);
|
||||
};
|
||||
window.addEventListener("beforeinstallprompt", onBeforeInstallPrompt);
|
||||
window.addEventListener("appinstalled", onInstalled);
|
||||
return () => {
|
||||
window.removeEventListener("beforeinstallprompt", onBeforeInstallPrompt);
|
||||
window.removeEventListener("appinstalled", onInstalled);
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (installed) {
|
||||
return <p className="text-muted text-sm">NextChair is installed on this device.</p>;
|
||||
return <p className="text-muted text-sm">{businessName} is installed on this device.</p>;
|
||||
}
|
||||
|
||||
if (deferredPrompt) {
|
||||
if (canPromptNatively) {
|
||||
return (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={async () => {
|
||||
await deferredPrompt.prompt();
|
||||
await deferredPrompt.userChoice;
|
||||
setDeferredPrompt(null);
|
||||
}}
|
||||
>
|
||||
<Button size="sm" onClick={promptInstall}>
|
||||
<Download className="h-4 w-4" aria-hidden="true" /> Install App
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
if (isIos()) {
|
||||
if (ios) {
|
||||
return showIosInstructions ? (
|
||||
<p className="text-muted text-sm">
|
||||
Tap the Share icon in Safari, then choose “Add to Home Screen”.
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useInstallPrompt } from "../hooks/use-install-prompt";
|
||||
|
||||
const DISMISSED_UNTIL_KEY = "nextchair:install-card-dismissed-until";
|
||||
/** Re-prompt interval after "Not Now" — see the brief's "reasonable re-prompt interval such as 14 or 30 days." 30 days chosen: this card is one of several places to install (Settings always has it too), so a longer, less-naggy interval is appropriate here specifically. */
|
||||
const DISMISS_DAYS = 30;
|
||||
|
||||
function isDismissed(): boolean {
|
||||
if (typeof window === "undefined") return true;
|
||||
const raw = window.localStorage.getItem(DISMISSED_UNTIL_KEY);
|
||||
if (!raw) return false;
|
||||
const until = Number(raw);
|
||||
return Number.isFinite(until) && Date.now() < until;
|
||||
}
|
||||
|
||||
/**
|
||||
* The prominent, inline Welcome-page counterpart to the Settings-page
|
||||
* `InstallAppButton` — same underlying detection (see
|
||||
* use-install-prompt.ts), never a second installer. Renders nothing when
|
||||
* already installed, on a non-iOS/non-installable browser, or recently
|
||||
* dismissed — it never blocks Sign In / Create Account / Continue as
|
||||
* Guest / Browse Services above it, and never opens as an unsolicited
|
||||
* modal (see the brief's explicit "do not automatically open a modal").
|
||||
*/
|
||||
export function WelcomeInstallCard({ businessName }: { businessName: string }) {
|
||||
const { installed, ios, canPromptNatively, promptInstall } = useInstallPrompt();
|
||||
const [dismissed, setDismissed] = useState(true);
|
||||
const [showIosSteps, setShowIosSteps] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
void Promise.resolve().then(() => setDismissed(isDismissed()));
|
||||
}, []);
|
||||
|
||||
const dismiss = () => {
|
||||
window.localStorage.setItem(DISMISSED_UNTIL_KEY, String(Date.now() + DISMISS_DAYS * 24 * 60 * 60 * 1000));
|
||||
setDismissed(true);
|
||||
};
|
||||
|
||||
if (installed || dismissed || (!canPromptNatively && !ios)) return null;
|
||||
|
||||
return (
|
||||
<div className="rounded-jumbo mb-6 border border-white/10 bg-white/5 p-5 text-white">
|
||||
<p className="text-sm font-bold">Add {businessName} to your Home Screen</p>
|
||||
<p className="mt-1 text-sm text-white/70">Book appointments, manage your visits and receive reminders more easily.</p>
|
||||
|
||||
{ios && showIosSteps ? (
|
||||
<div className="mt-4 space-y-2 text-sm text-white/80">
|
||||
<p className="font-bold text-white">Install {businessName}</p>
|
||||
<ol className="list-inside list-decimal space-y-1">
|
||||
<li>Tap the Share button in Safari.</li>
|
||||
<li>Scroll down and choose “Add to Home Screen”.</li>
|
||||
<li>Tap Add.</li>
|
||||
</ol>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-4 flex gap-3">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
className="bg-white"
|
||||
onClick={ios ? () => setShowIosSteps(true) : promptInstall}
|
||||
>
|
||||
Add to Home Screen
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" className="text-white/70 hover:bg-white/10 hover:text-white" onClick={dismiss}>
|
||||
Not Now
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export interface BeforeInstallPromptEvent extends Event {
|
||||
prompt: () => Promise<void>;
|
||||
userChoice: Promise<{ outcome: "accepted" | "dismissed" }>;
|
||||
}
|
||||
|
||||
function isStandalone(): boolean {
|
||||
if (typeof window === "undefined") return false;
|
||||
return (
|
||||
window.matchMedia("(display-mode: standalone)").matches ||
|
||||
// iOS Safari's own (non-standard, but the only signal it exposes).
|
||||
(window.navigator as Navigator & { standalone?: boolean }).standalone === true
|
||||
);
|
||||
}
|
||||
|
||||
function isIos(): boolean {
|
||||
if (typeof navigator === "undefined") return false;
|
||||
return /iphone|ipad|ipod/i.test(navigator.userAgent);
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared `beforeinstallprompt` capture + standalone/iOS detection — the
|
||||
* one PWA install implementation both `InstallAppButton` (Settings, every
|
||||
* role) and `WelcomeInstallCard` (the unauthenticated Welcome page) use.
|
||||
* See the brief's explicit "do not create a competing second installer".
|
||||
* All detection lives in `useEffect`, never a `useState` lazy
|
||||
* initializer — reading `window`/`navigator` during the initializer would
|
||||
* run during SSR too and mismatch the client's first render (the same
|
||||
* hydration bug this app's push-subscribe-toggle.tsx fixed in Phase 7).
|
||||
*/
|
||||
export function useInstallPrompt() {
|
||||
const [deferredPrompt, setDeferredPrompt] = useState<BeforeInstallPromptEvent | null>(null);
|
||||
const [installed, setInstalled] = useState(false);
|
||||
const [ios, setIos] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// Async chain, not a direct setState call in the effect body — same
|
||||
// pattern push-subscribe-toggle.tsx uses (see that file's own
|
||||
// comment) to satisfy react-hooks/set-state-in-effect while still
|
||||
// reading browser-only APIs that can't run during SSR.
|
||||
void Promise.resolve().then(() => {
|
||||
setInstalled(isStandalone());
|
||||
setIos(isIos());
|
||||
});
|
||||
|
||||
const onBeforeInstallPrompt = (event: Event) => {
|
||||
event.preventDefault();
|
||||
setDeferredPrompt(event as BeforeInstallPromptEvent);
|
||||
};
|
||||
const onInstalled = () => {
|
||||
setInstalled(true);
|
||||
setDeferredPrompt(null);
|
||||
};
|
||||
window.addEventListener("beforeinstallprompt", onBeforeInstallPrompt);
|
||||
window.addEventListener("appinstalled", onInstalled);
|
||||
return () => {
|
||||
window.removeEventListener("beforeinstallprompt", onBeforeInstallPrompt);
|
||||
window.removeEventListener("appinstalled", onInstalled);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const promptInstall = async (): Promise<void> => {
|
||||
if (!deferredPrompt) return;
|
||||
await deferredPrompt.prompt();
|
||||
await deferredPrompt.userChoice;
|
||||
setDeferredPrompt(null);
|
||||
};
|
||||
|
||||
return { installed, ios, canPromptNatively: !!deferredPrompt, promptInstall };
|
||||
}
|
||||
Reference in new issue
Block a user