+
-
window.location.reload()} title="Reload">
-
-
-
-
-
-
window.location.reload()}
+ label={tToolbar("reload")}
>
+
+
+ {isFullscreen ? (
+
+ ) : (
+
+ )}
+
+
+
+ {emulatorOk === false ? (
+
+ ) : (
+
+ )}
+
+
+
{
+ setShowOnlineUsers(true);
+ refreshOnlineUsers();
+ }}
+ onMouseLeave={() => setShowOnlineUsers(false)}
+ >
+
+
+
+
+
{onlineCount}
+ {showOnlineUsers && onlineUsers.length > 0 && (
+
+ {onlineUsers.slice(0, 8).join(", ")}
+ {onlineUsers.length > 8
+ ? ` +${onlineUsers.length - 8} more`
+ : ""}
+
+ )}
+ {/* biome-ignore lint/a11y/noStaticElementInteractions: grip handle explicitly initiates a drag (mouse+touch) and is not a button */}
void signOutAndRevokeTicket()}
- title="Logout"
+ label={tToolbar("logout")}
>
+
setBarHidden(true)}
+ label={tToolbar("hide")}
+ >
+
+
) : null}
+ {barHidden && pos ? (
+ (
+ "/api/online/count/stream",
+ { enabled: true },
+ );
+ useEffect(() => {
+ if (onlineStream && typeof onlineStream.count === "number") {
+ setCount(onlineStream.count);
+ }
+ }, [onlineStream]);
+
+ return <>{t("online", { count, hotel: hotelName })}>;
+}
diff --git a/src/components/live-online-counter.tsx b/src/components/live-online-counter.tsx
new file mode 100644
index 00000000..aecb4fdc
--- /dev/null
+++ b/src/components/live-online-counter.tsx
@@ -0,0 +1,32 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import { AnimatedCounter } from "@/components/animated-counter";
+import { useEventSource } from "@/lib/use-event-source";
+
+/**
+ * Live animated online-count number. Subscribes to the SSE online-count stream
+ * so the stat updates in real-time (multicast) instead of only the initial SSR
+ * value, while keeping the entrance counter animation.
+ */
+export function LiveOnlineCounter({
+ initial,
+ suffix = "",
+}: {
+ initial: number;
+ suffix?: string;
+}) {
+ const [count, setCount] = useState(initial);
+
+ const { data: onlineStream } = useEventSource<{ count: number }>(
+ "/api/online/count/stream",
+ { enabled: true },
+ );
+ useEffect(() => {
+ if (onlineStream && typeof onlineStream.count === "number") {
+ setCount(onlineStream.count);
+ }
+ }, [onlineStream]);
+
+ return
;
+}
diff --git a/src/components/site-header.tsx b/src/components/site-header.tsx
index 81c51095..66fa0a36 100644
--- a/src/components/site-header.tsx
+++ b/src/components/site-header.tsx
@@ -1,14 +1,13 @@
import { count, eq } from "drizzle-orm";
import Image from "next/image";
-import { getTranslations } from "next-intl/server";
import Link from "@/components/link";
+import { LiveOnlineCount } from "@/components/live-online-count";
import { cached } from "@/lib/cache";
import { db, User } from "@/lib/db";
import { resolveHotelName } from "@/lib/hotel-name";
import { siteSettings } from "@/lib/services/site-settings";
export async function SiteHeader() {
- const t = await getTranslations("header");
const [hotelName, header, logo] = await Promise.all([
resolveHotelName(),
siteSettings.get("cms_header", "/assets/images/background.png"),
@@ -140,7 +139,7 @@ export async function SiteHeader() {
className="text-xs sm:text-sm font-bold tracking-wide"
style={{ color: "rgba(255,255,255,0.9)" }}
>
- {t("online", { count: online, hotel: hotelName })}
+