Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions public/news/news.json
Original file line number Diff line number Diff line change
@@ -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"
}
]
3 changes: 3 additions & 0 deletions src/shared/components/Header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -240,6 +241,8 @@ export default function Header({
)}
</div>

{pathname === "/home" && <NewsTicker />}

{/* Right actions */}
<div className="flex items-center gap-3 ml-auto">
{onOpenCommandPalette && (
Expand Down
110 changes: 110 additions & 0 deletions src/shared/components/NewsTicker.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
"use client";

import { useEffect, useState } from "react";
import { useLocale } from "next-intl";

type NewsItem = {
id: string;
text: Record<string, string>;
link?: string;
};

export default function NewsTicker() {
const locale = useLocale();
const [news, setNews] = useState<NewsItem[]>([]);
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 = (
<div
className={`flex items-center gap-2.5 text-xs text-text-main transition-opacity duration-300 ${
isVisible ? "opacity-100" : "opacity-0"
}`}
>
<span className="text-sm select-none shrink-0 animate-pulse">🔥</span>
<span className="font-semibold text-primary uppercase tracking-wider text-[9px] bg-primary/10 px-2 py-0.5 rounded shrink-0">
News
</span>
<span className="truncate max-w-[200px] md:max-w-[300px] lg:max-w-[400px] xl:max-w-[550px] font-medium text-text-main hover:text-primary transition-colors">
{displayText}
</span>
{currentItem.link && (
<span className="material-symbols-outlined text-[13px] text-text-muted shrink-0">
open_in_new
</span>
)}
</div>
);

return (
<div className="hidden lg:flex items-center mx-auto max-w-full">
{currentItem.link ? (
<a
href={currentItem.link}
target="_blank"
rel="noopener noreferrer"
className="flex items-center px-4.5 py-1.5 rounded-full bg-bg-subtle border border-border hover:border-primary/30 dark:hover:border-primary/30 hover:shadow-xs transition-all duration-300"
>
{content}
</a>
) : (
<div className="flex items-center px-4.5 py-1.5 rounded-full bg-bg-subtle border border-border">
{content}
</div>
)}
</div>
);
}
122 changes: 122 additions & 0 deletions tests/unit/ui/NewsTicker.test.tsx
Original file line number Diff line number Diff line change
@@ -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<string, string>; 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<HTMLElement> {
const container = document.createElement("div");
document.body.appendChild(container);
containers.push(container);

const root = createRoot(container);
await act(async () => {
root.render(<NewsTicker />);
});
// 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("");
});
});
Loading