feat(ops): VersionWatcher — toast prompt when a new deploy is live

Client-side polling component mounted in providers.tsx. At mount,
captures the initial commit from /api/version. Every 60s, re-checks.
If commit differs from the captured one → renders a dismissible toast
in the bottom-right offering a hard reload.

Useful because Next.js bundles cache per commit hash → after a deploy
users would otherwise keep running the old client until they manually
refresh. Now they get a discoverable nudge.

Banner UX:
- "Versiune nouă disponibilă: <shortSha> · apasă pentru reîncărcare"
- [Reîncarcă] button (window.location.reload)
- [X] dismiss for current page life
- Tailwind animate-in fade slide-from-bottom

Polling interval 60s is fine for our deploy frequency; cheap (one
GET per minute, ~150 bytes). Cache-busted with cache: "no-store".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Claude VM
2026-05-18 23:16:18 +03:00
parent 64bccdb4b0
commit 382940112f
2 changed files with 78 additions and 0 deletions
+2
View File
@@ -5,6 +5,7 @@ import { I18nProvider } from '@/core/i18n';
import { StorageProvider } from '@/core/storage'; import { StorageProvider } from '@/core/storage';
import { FeatureFlagProvider } from '@/core/feature-flags'; import { FeatureFlagProvider } from '@/core/feature-flags';
import { AuthProvider } from '@/core/auth'; import { AuthProvider } from '@/core/auth';
import { VersionWatcher } from '@/core/version/version-watcher';
import { DEFAULT_FLAGS } from '@/config/flags'; import { DEFAULT_FLAGS } from '@/config/flags';
// Ensure module registry is populated // Ensure module registry is populated
@@ -22,6 +23,7 @@ export function Providers({ children }: ProvidersProps) {
<FeatureFlagProvider flagDefinitions={DEFAULT_FLAGS}> <FeatureFlagProvider flagDefinitions={DEFAULT_FLAGS}>
<AuthProvider> <AuthProvider>
{children} {children}
<VersionWatcher />
</AuthProvider> </AuthProvider>
</FeatureFlagProvider> </FeatureFlagProvider>
</StorageProvider> </StorageProvider>
+76
View File
@@ -0,0 +1,76 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { RefreshCw, X } from "lucide-react";
const POLL_INTERVAL_MS = 60_000;
const ENDPOINT = "/api/version";
type VersionPayload = { commit: string; commitShort: string; buildTime: string };
export function VersionWatcher() {
const initialRef = useRef<string | null>(null);
const [latest, setLatest] = useState<VersionPayload | null>(null);
const [dismissed, setDismissed] = useState(false);
useEffect(() => {
let cancelled = false;
const fetchVersion = async () => {
try {
const res = await fetch(ENDPOINT, { cache: "no-store" });
if (!res.ok || cancelled) return;
const data = (await res.json()) as VersionPayload;
if (!data?.commit) return;
if (initialRef.current === null) {
initialRef.current = data.commit;
return;
}
if (data.commit !== initialRef.current) {
setLatest(data);
}
} catch {
// network noise — ignore
}
};
fetchVersion();
const interval = setInterval(fetchVersion, POLL_INTERVAL_MS);
return () => {
cancelled = true;
clearInterval(interval);
};
}, []);
if (!latest || dismissed) return null;
return (
<div
role="status"
className="fixed bottom-4 right-4 z-[60] flex items-center gap-2 rounded-lg border bg-background/95 px-3 py-2 shadow-lg backdrop-blur animate-in fade-in slide-in-from-bottom-2"
>
<RefreshCw className="h-4 w-4 text-primary" />
<div className="flex flex-col text-xs">
<span className="font-medium">Versiune nouă disponibilă</span>
<span className="text-muted-foreground">
<code className="font-mono">{latest.commitShort}</code> · apasă pentru reîncărcare
</span>
</div>
<button
type="button"
onClick={() => window.location.reload()}
className="ml-2 rounded bg-primary px-2 py-1 text-xs font-medium text-primary-foreground hover:bg-primary/90"
>
Reîncarcă
</button>
<button
type="button"
onClick={() => setDismissed(true)}
className="rounded p-1 text-muted-foreground hover:bg-muted hover:text-foreground"
aria-label="Închide"
>
<X className="h-3.5 w-3.5" />
</button>
</div>
);
}