Revert "PHP Rebuild"

This reverts commit 2ef3654841.
This commit is contained in:
Stefan Lazic committed 2026-09-05 09:32:05 +01:00
1 parent a59f998f2b
commit 2aa8405006
690 files changed
+12045 -33943

No files matched your search

@@ -0,0 +1,47 @@
"use client";
import { Download } from "lucide-react";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { useInstallPrompt } from "../hooks/use-install-prompt";
/**
* Chrome/Edge/Android fire `beforeinstallprompt` when the manifest +
* 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({ businessName }: { businessName: string }) {
const { installed, ios, canPromptNatively, promptInstall } = useInstallPrompt();
const [showIosInstructions, setShowIosInstructions] = useState(false);
if (installed) {
return <p className="text-muted text-sm">{businessName} is installed on this device.</p>;
}
if (canPromptNatively) {
return (
<Button size="sm" onClick={promptInstall}>
<Download className="h-4 w-4" aria-hidden="true" /> Install App
</Button>
);
}
if (ios) {
return showIosInstructions ? (
<p className="text-muted text-sm">
Tap the Share icon in Safari, then choose &ldquo;Add to Home Screen&rdquo;.
</p>
) : (
<Button size="sm" variant="secondary" onClick={() => setShowIosInstructions(true)}>
<Download className="h-4 w-4" aria-hidden="true" /> Install App
</Button>
);
}
return <p className="text-muted text-sm">Installing isn&apos;t available in this browser yet.</p>;
}
@@ -0,0 +1,81 @@
"use client";
import { useEffect, useState } from "react";
/**
* Registers the service worker once, app-wide, and surfaces the
* update-available handshake public/sw.js sets up: a new SW version
* installs and sits `waiting` rather than taking over immediately, so an
* open tab isn't silently switched to a new app version mid-session. This
* component detects that waiting worker and lets the user opt into the
* refresh instead.
*/
export function ServiceWorkerManager() {
const [waitingWorker, setWaitingWorker] = useState<ServiceWorker | null>(null);
const [dismissed, setDismissed] = useState(false);
useEffect(() => {
if (!("serviceWorker" in navigator)) return;
navigator.serviceWorker
.register("/sw.js")
.then((registration) => {
// An update was already downloaded (e.g. by another tab) before this page loaded.
if (registration.waiting && navigator.serviceWorker.controller) {
setWaitingWorker(registration.waiting);
}
registration.addEventListener("updatefound", () => {
const installing = registration.installing;
if (!installing) return;
installing.addEventListener("statechange", () => {
// Only a real update — an installing worker with no existing
// controller is just this tab's first-ever SW install, not an update.
if (installing.state === "installed" && navigator.serviceWorker.controller) {
setWaitingWorker(installing);
}
});
});
})
.catch(() => {
// Best-effort — offline/install/update support degrades gracefully without it.
});
let reloaded = false;
const onControllerChange = () => {
if (reloaded) return;
reloaded = true;
window.location.reload();
};
navigator.serviceWorker.addEventListener("controllerchange", onControllerChange);
return () => navigator.serviceWorker.removeEventListener("controllerchange", onControllerChange);
}, []);
if (!waitingWorker || dismissed) return null;
return (
<div
role="status"
className="rounded-md bg-ink fixed inset-x-6 top-4 z-50 mx-auto flex max-w-sm items-center justify-between gap-3 px-5 py-3 text-sm text-white shadow-xl"
>
<span>A new version is available.</span>
<div className="flex items-center gap-3">
<button
type="button"
className="font-bold whitespace-nowrap underline"
onClick={() => waitingWorker.postMessage({ type: "SKIP_WAITING" })}
>
Refresh
</button>
<button
type="button"
aria-label="Dismiss"
className="text-white/60"
onClick={() => setDismissed(true)}
>
✕
</button>
</div>
</div>
);
}
@@ -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-md 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 &ldquo;Add to Home Screen&rdquo;.</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 };
}