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:
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user