diff --git a/public/news/news.json b/public/news/news.json
new file mode 100644
index 00000000000..2690eaddffd
--- /dev/null
+++ b/public/news/news.json
@@ -0,0 +1,20 @@
+[
+ {
+ "id": "course",
+ "text": {
+ "pt-BR": "Curso Omniroute com Diego Souza: Aprenda e seja expert em omniroute",
+ "pt": "Curso Omniroute com Diego Souza: Aprenda e seja expert em omniroute",
+ "en": "Omniroute Course with Diego Souza: Learn and be an omniroute expert"
+ },
+ "link": "https://course.mundoautomaik.com"
+ },
+ {
+ "id": "whatsapp",
+ "text": {
+ "pt-BR": "Grupo Whatsapp Omnirouter Brazil",
+ "pt": "Grupo Whatsapp Omnirouter Brazil",
+ "en": "Omnirouter Brazil WhatsApp Group"
+ },
+ "link": "https://chat.whatsapp.com/BTGJXIyjeNIIgExvTMGGhI"
+ }
+]
diff --git a/src/shared/components/Header.tsx b/src/shared/components/Header.tsx
index 94341613fc0..925fd10ca1c 100644
--- a/src/shared/components/Header.tsx
+++ b/src/shared/components/Header.tsx
@@ -15,6 +15,7 @@ import TokenHealthBadge from "./TokenHealthBadge";
import DegradationBadge from "./DegradationBadge";
import LanguageSelector from "./LanguageSelector";
import ProviderIcon from "./ProviderIcon";
+import NewsTicker from "./NewsTicker";
import { useTranslations } from "next-intl";
import {
OAUTH_PROVIDERS,
@@ -240,6 +241,8 @@ export default function Header({
)}
+ {pathname === "/home" && }
+
{/* Right actions */}
{onOpenCommandPalette && (
diff --git a/src/shared/components/NewsTicker.tsx b/src/shared/components/NewsTicker.tsx
new file mode 100644
index 00000000000..ac26a4783ac
--- /dev/null
+++ b/src/shared/components/NewsTicker.tsx
@@ -0,0 +1,110 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import { useLocale } from "next-intl";
+
+type NewsItem = {
+ id: string;
+ text: Record
;
+ link?: string;
+};
+
+export default function NewsTicker() {
+ const locale = useLocale();
+ const [news, setNews] = useState([]);
+ const [currentIndex, setCurrentIndex] = useState(0);
+ const [isVisible, setIsVisible] = useState(true);
+
+ useEffect(() => {
+ let active = true;
+ fetch("/news/news.json")
+ .then((res) => {
+ if (!res.ok) throw new Error("Failed to fetch news");
+ return res.json();
+ })
+ .then((data) => {
+ if (active && Array.isArray(data)) {
+ setNews(data);
+ }
+ })
+ .catch((err) => {
+ console.error("Error loading news.json:", err);
+ });
+ return () => {
+ active = false;
+ };
+ }, []);
+
+ useEffect(() => {
+ if (news.length <= 1) return;
+
+ const interval = setInterval(() => {
+ setIsVisible(false);
+ setTimeout(() => {
+ setCurrentIndex((prev) => (prev + 1) % news.length);
+ setIsVisible(true);
+ }, 400); // Wait for fade out to finish
+ }, 10000); // 10 seconds
+
+ return () => clearInterval(interval);
+ }, [news]);
+
+ if (news.length === 0) return null;
+
+ const currentItem = news[currentIndex];
+
+ // Helper to get localized text
+ const getLocalizedText = (item: NewsItem, lang: string): string => {
+ if (!item.text) return "";
+ if (item.text[lang]) return item.text[lang];
+ const base = lang.split("-")[0];
+ if (item.text[base]) return item.text[base];
+ if (item.text["en"]) return item.text["en"];
+ if (item.text["pt"]) return item.text["pt"];
+ const keys = Object.keys(item.text);
+ return keys.length > 0 ? item.text[keys[0]] : "";
+ };
+
+ const displayText = getLocalizedText(currentItem, locale);
+ if (!displayText) return null;
+
+ const content = (
+
+ π₯
+
+ News
+
+
+ {displayText}
+
+ {currentItem.link && (
+
+ open_in_new
+
+ )}
+
+ );
+
+ return (
+
+ {currentItem.link ? (
+
+ {content}
+
+ ) : (
+
+ {content}
+
+ )}
+
+ );
+}
diff --git a/tests/unit/ui/NewsTicker.test.tsx b/tests/unit/ui/NewsTicker.test.tsx
new file mode 100644
index 00000000000..825829b4398
--- /dev/null
+++ b/tests/unit/ui/NewsTicker.test.tsx
@@ -0,0 +1,122 @@
+// @vitest-environment jsdom
+import React from "react";
+import { act } from "react";
+import { createRoot } from "react-dom/client";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+// ββ Mocks βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+
+let currentLocale = "en";
+
+vi.mock("next-intl", () => ({
+ useLocale: () => currentLocale,
+}));
+
+// ββ Import after mocks ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+
+const { default: NewsTicker } = await import("@/shared/components/NewsTicker");
+
+// ββ Helpers βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+
+const containers: HTMLElement[] = [];
+
+type NewsItem = { id: string; text: Record; link?: string };
+
+function mockNews(items: NewsItem[] | "reject"): void {
+ globalThis.fetch = vi.fn(async () => {
+ if (items === "reject") {
+ return { ok: false, json: async () => [] } as unknown as Response;
+ }
+ return { ok: true, json: async () => items } as unknown as Response;
+ }) as typeof fetch;
+}
+
+async function renderTicker(): Promise {
+ const container = document.createElement("div");
+ document.body.appendChild(container);
+ containers.push(container);
+
+ const root = createRoot(container);
+ await act(async () => {
+ root.render();
+ });
+ // Flush the fetch microtask chain in the mount effect.
+ await act(async () => {
+ await Promise.resolve();
+ await Promise.resolve();
+ });
+ return container;
+}
+
+// ββ Lifecycle βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+
+beforeEach(() => {
+ (
+ globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
+ ).IS_REACT_ACT_ENVIRONMENT = true;
+ currentLocale = "en";
+});
+
+afterEach(() => {
+ while (containers.length > 0) {
+ containers.pop()?.remove();
+ }
+ document.body.innerHTML = "";
+ vi.restoreAllMocks();
+});
+
+// ββ Tests βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+
+const SAMPLE: NewsItem[] = [
+ {
+ id: "course",
+ text: { "pt-BR": "Curso Omniroute", pt: "Curso Omniroute", en: "Omniroute Course" },
+ link: "https://course.example.com",
+ },
+];
+
+describe("NewsTicker", () => {
+ it("renders the text localized to the active locale", async () => {
+ currentLocale = "en";
+ mockNews(SAMPLE);
+ const container = await renderTicker();
+ expect(container.textContent).toContain("Omniroute Course");
+ });
+
+ it("falls back to the base language when the exact locale is missing", async () => {
+ // Only "pt" exists, active locale is the regional "pt-PT" β base "pt" wins.
+ currentLocale = "pt-PT";
+ mockNews([{ id: "x", text: { pt: "NotΓcia PT", en: "News EN" } }]);
+ const container = await renderTicker();
+ expect(container.textContent).toContain("NotΓcia PT");
+ });
+
+ it("falls back to English when neither the locale nor its base exist", async () => {
+ currentLocale = "fr-FR";
+ mockNews([{ id: "x", text: { en: "News EN", pt: "NotΓcia PT" } }]);
+ const container = await renderTicker();
+ expect(container.textContent).toContain("News EN");
+ });
+
+ it("renders a link with safe rel/target when an item has a link", async () => {
+ mockNews(SAMPLE);
+ const container = await renderTicker();
+ const anchor = container.querySelector("a");
+ expect(anchor).not.toBeNull();
+ expect(anchor?.getAttribute("href")).toBe("https://course.example.com");
+ expect(anchor?.getAttribute("rel")).toContain("noopener");
+ expect(anchor?.getAttribute("target")).toBe("_blank");
+ });
+
+ it("renders nothing when the news feed is empty", async () => {
+ mockNews([]);
+ const container = await renderTicker();
+ expect(container.textContent).toBe("");
+ });
+
+ it("renders nothing when the fetch fails", async () => {
+ mockNews("reject");
+ const container = await renderTicker();
+ expect(container.textContent).toBe("");
+ });
+});