- Integrated `next-intl` for toast messages and locale handling in various components, including `Providers`, `CanvasUserMenu`, and `CreditOverview`. - Replaced hardcoded strings with translation keys to enhance localization capabilities. - Updated `RootLayout` to dynamically set the language attribute based on the user's locale. - Ensured consistent user feedback through localized toast messages in actions such as sign-out, canvas operations, and billing notifications.
163 lines
4.9 KiB
TypeScript
163 lines
4.9 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useMemo, useRef, useState } from "react";
|
|
import { useConvexConnectionState } from "convex/react";
|
|
import { useTranslations } from "next-intl";
|
|
|
|
import { cn } from "@/lib/utils";
|
|
import { toast, toastDuration } from "@/lib/toast";
|
|
|
|
type BannerState = "hidden" | "reconnecting" | "disconnected" | "reconnected";
|
|
|
|
const RECONNECTED_HIDE_DELAY_MS = 1800;
|
|
|
|
export default function ConnectionBanner() {
|
|
const t = useTranslations('toasts');
|
|
const connectionState = useConvexConnectionState();
|
|
const previousConnectedRef = useRef(connectionState.isWebSocketConnected);
|
|
const disconnectToastIdRef = useRef<string | number | undefined>(undefined);
|
|
const [showReconnected, setShowReconnected] = useState(false);
|
|
const [isBrowserOnline, setIsBrowserOnline] = useState(
|
|
typeof navigator === "undefined" ? true : navigator.onLine,
|
|
);
|
|
|
|
useEffect(() => {
|
|
const handleOnline = () => setIsBrowserOnline(true);
|
|
const handleOffline = () => setIsBrowserOnline(false);
|
|
|
|
window.addEventListener("online", handleOnline);
|
|
window.addEventListener("offline", handleOffline);
|
|
|
|
return () => {
|
|
window.removeEventListener("online", handleOnline);
|
|
window.removeEventListener("offline", handleOffline);
|
|
};
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
const wasConnected = previousConnectedRef.current;
|
|
const isConnected = connectionState.isWebSocketConnected;
|
|
const didReconnect =
|
|
!wasConnected && isConnected && connectionState.connectionCount > 1;
|
|
|
|
if (didReconnect) {
|
|
queueMicrotask(() => {
|
|
setShowReconnected(true);
|
|
});
|
|
}
|
|
|
|
if (!isConnected) {
|
|
queueMicrotask(() => {
|
|
setShowReconnected(false);
|
|
});
|
|
}
|
|
|
|
previousConnectedRef.current = isConnected;
|
|
}, [connectionState.connectionCount, connectionState.isWebSocketConnected]);
|
|
|
|
useEffect(() => {
|
|
if (!showReconnected) {
|
|
return;
|
|
}
|
|
|
|
const timeoutId = window.setTimeout(() => {
|
|
setShowReconnected(false);
|
|
}, RECONNECTED_HIDE_DELAY_MS);
|
|
|
|
return () => window.clearTimeout(timeoutId);
|
|
}, [showReconnected]);
|
|
|
|
useEffect(() => {
|
|
const connected = connectionState.isWebSocketConnected;
|
|
const shouldAlertDisconnect =
|
|
!connected &&
|
|
(!isBrowserOnline ||
|
|
connectionState.hasEverConnected ||
|
|
connectionState.connectionRetries > 0);
|
|
|
|
if (shouldAlertDisconnect) {
|
|
if (disconnectToastIdRef.current === undefined) {
|
|
disconnectToastIdRef.current = toast.error(
|
|
t('system.connectionLostTitle'),
|
|
t('system.connectionLostDesc'),
|
|
{ duration: Number.POSITIVE_INFINITY },
|
|
);
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (connected && disconnectToastIdRef.current !== undefined) {
|
|
toast.dismiss(disconnectToastIdRef.current);
|
|
disconnectToastIdRef.current = undefined;
|
|
toast.success(t('system.reconnected'), undefined, {
|
|
duration: toastDuration.successShort,
|
|
});
|
|
}
|
|
}, [
|
|
t,
|
|
connectionState.connectionRetries,
|
|
connectionState.hasEverConnected,
|
|
connectionState.isWebSocketConnected,
|
|
isBrowserOnline,
|
|
]);
|
|
|
|
const bannerState = useMemo<BannerState>(() => {
|
|
if (connectionState.isWebSocketConnected) {
|
|
return showReconnected ? "reconnected" : "hidden";
|
|
}
|
|
|
|
// Streng `=== false`, damit kein undefined/SSR-Artefakt wie „offline“ wird.
|
|
if (isBrowserOnline === false) {
|
|
return "disconnected";
|
|
}
|
|
|
|
if (connectionState.hasEverConnected || connectionState.connectionRetries > 0) {
|
|
return "reconnecting";
|
|
}
|
|
|
|
return "hidden";
|
|
}, [
|
|
connectionState.connectionRetries,
|
|
connectionState.hasEverConnected,
|
|
connectionState.isWebSocketConnected,
|
|
isBrowserOnline,
|
|
showReconnected,
|
|
]);
|
|
|
|
// WebSocket/Convex-Verbindung gibt es im Browser; SSR soll keinen Banner rendern,
|
|
// sonst weicht die Geschwister-Reihenfolge vom ersten Client-Render ab (Hydration).
|
|
if (typeof window === "undefined") {
|
|
return null;
|
|
}
|
|
|
|
if (bannerState === "hidden") {
|
|
return null;
|
|
}
|
|
|
|
const contentByState: Record<Exclude<BannerState, "hidden">, { dotClass: string; text: string }> = {
|
|
reconnecting: {
|
|
dotClass: "bg-amber-500",
|
|
text: "Verbindung wird wiederhergestellt…",
|
|
},
|
|
disconnected: {
|
|
dotClass: "bg-destructive",
|
|
text: "Keine Verbindung. Wir verbinden uns automatisch erneut.",
|
|
},
|
|
reconnected: {
|
|
dotClass: "bg-emerald-500",
|
|
text: "Verbindung wiederhergestellt",
|
|
},
|
|
};
|
|
|
|
const content = contentByState[bannerState];
|
|
|
|
return (
|
|
<div className="pointer-events-none absolute top-3 left-1/2 z-20 -translate-x-1/2">
|
|
<div className="inline-flex items-center gap-2 rounded-full border border-border/70 bg-card/90 px-3 py-1.5 text-xs text-muted-foreground shadow-sm backdrop-blur-sm">
|
|
<span className={cn("h-1.5 w-1.5 rounded-full", content.dotClass)} aria-hidden="true" />
|
|
<span>{content.text}</span>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|