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(""); + }); +});