diff --git a/src/components/animated-counter.tsx b/src/components/animated-counter.tsx index e7972585..ce617b76 100644 --- a/src/components/animated-counter.tsx +++ b/src/components/animated-counter.tsx @@ -1,6 +1,6 @@ "use client"; -import { motion, useInView } from "motion/react"; +import { motion, useInView, useReducedMotion } from "motion/react"; import { useEffect, useRef, useState } from "react"; export function AnimatedCounter({ @@ -12,6 +12,7 @@ export function AnimatedCounter({ }) { const ref = useRef(null); const isInView = useInView(ref, { once: true, margin: "-50px" }); + const prefersReducedMotion = useReducedMotion(); const started = useRef(false); const [display, setDisplay] = useState(0); @@ -24,6 +25,11 @@ export function AnimatedCounter({ } started.current = true; + if (prefersReducedMotion) { + setDisplay(value); + return; + } + const duration = 1200; const start = performance.now(); let raf = 0; @@ -41,7 +47,7 @@ export function AnimatedCounter({ raf = requestAnimationFrame(tick); return () => cancelAnimationFrame(raf); - }, [isInView, value]); + }, [isInView, value, prefersReducedMotion]); return ( ( - "/api/online/count/stream", - { enabled: true }, - ); - useEffect(() => { - if (onlineStream && typeof onlineStream.count === "number") { - setCount(onlineStream.count); - } - }, [onlineStream]); + const count = useOnlineCount(initial); return <>{t("online", { count, hotel: hotelName })}; } diff --git a/src/components/live-online-counter.tsx b/src/components/live-online-counter.tsx index aecb4fdc..f054a1f1 100644 --- a/src/components/live-online-counter.tsx +++ b/src/components/live-online-counter.tsx @@ -1,13 +1,12 @@ "use client"; -import { useEffect, useState } from "react"; import { AnimatedCounter } from "@/components/animated-counter"; -import { useEventSource } from "@/lib/use-event-source"; +import { useOnlineCount } from "@/lib/online-count-sse"; /** - * 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. + * Live animated online-count number. Shares a single SSE connection (see + * `useOnlineCount`) so the whole page uses one socket instead of one per + * counter, while keeping the entrance counter animation. */ export function LiveOnlineCounter({ initial, @@ -16,17 +15,7 @@ export function LiveOnlineCounter({ 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]); + const count = useOnlineCount(initial); return ; } diff --git a/src/lib/online-count-sse.ts b/src/lib/online-count-sse.ts new file mode 100644 index 00000000..ffd2820b --- /dev/null +++ b/src/lib/online-count-sse.ts @@ -0,0 +1,77 @@ +"use client"; + +import { useEffect, useState } from "react"; + +type Listener = (count: number) => void; + +/* Single shared EventSource for /api/online/count/stream. Every subscribing + * component re-uses one connection (multicast to all listeners) instead of + * opening its own SSE socket per component. */ +const MAX_RETRIES = 8; +const BASE_DELAY_MS = 1_000; +const MAX_DELAY_MS = 30_000; + +let eventSource: EventSource | null = null; +const listeners = new Set(); +let refCount = 0; +let reconnectTimer: ReturnType | undefined; +let attempts = 0; + +function start() { + if (eventSource || refCount === 0) return; + eventSource = new EventSource("/api/online/count/stream"); + eventSource.onopen = () => { + attempts = 0; + }; + eventSource.onmessage = (event) => { + try { + const parsed = JSON.parse(event.data) as { count?: number }; + if (typeof parsed.count === "number") { + const count = parsed.count; + listeners.forEach((listener) => { + listener(count); + }); + } + } catch { + // ignore malformed messages + } + }; + eventSource.onerror = () => { + eventSource?.close(); + eventSource = null; + if (refCount === 0 || attempts >= MAX_RETRIES) return; + const delay = Math.min(BASE_DELAY_MS * 2 ** attempts, MAX_DELAY_MS); + attempts++; + reconnectTimer = setTimeout(start, delay); + }; +} + +function stop() { + if (refCount > 0 || !eventSource) return; + if (reconnectTimer !== undefined) clearTimeout(reconnectTimer); + eventSource.close(); + eventSource = null; + attempts = 0; +} + +/** + * Live online-user count shared by the header and the hero/stats counters. + * Falls back to the server-rendered `initial` value until the stream reports. + */ +export function useOnlineCount(initial: number): number { + const [count, setCount] = useState(initial); + + useEffect(() => { + const listener: Listener = setCount; + refCount++; + listeners.add(listener); + start(); + return () => { + listeners.delete(listener); + refCount--; + stop(); + }; + }, []); + + return count; +}