diff --git a/src/app/(dashboard)/dashboard/translator/components/TranslateFlowDiagram.tsx b/src/app/(dashboard)/dashboard/translator/components/TranslateFlowDiagram.tsx new file mode 100644 index 0000000000..d9e144ff29 --- /dev/null +++ b/src/app/(dashboard)/dashboard/translator/components/TranslateFlowDiagram.tsx @@ -0,0 +1,122 @@ +"use client"; + +import { useCallback, type ReactNode } from "react"; +import { useTranslations } from "next-intl"; +import Tooltip from "@/shared/components/Tooltip"; + +interface FlowNodeProps { + icon: string; + color: "primary" | "orange" | "blue" | "emerald" | "amber" | "purple" | "cyan" | "pink"; + title: string; + example: string; + tooltipContent?: string; +} + +const COLOR_MAP: Record< + FlowNodeProps["color"], + { border: string; bg: string; text: string } +> = { + primary: { border: "border-primary/30", bg: "bg-primary/5", text: "text-primary" }, + orange: { border: "border-orange-500/30", bg: "bg-orange-500/5", text: "text-orange-500" }, + blue: { border: "border-blue-500/30", bg: "bg-blue-500/5", text: "text-blue-500" }, + emerald: { + border: "border-emerald-500/30", + bg: "bg-emerald-500/5", + text: "text-emerald-500", + }, + amber: { border: "border-amber-500/30", bg: "bg-amber-500/5", text: "text-amber-500" }, + purple: { + border: "border-purple-500/30", + bg: "bg-purple-500/5", + text: "text-purple-500", + }, + cyan: { border: "border-cyan-500/30", bg: "bg-cyan-500/5", text: "text-cyan-500" }, + pink: { border: "border-pink-500/30", bg: "bg-pink-500/5", text: "text-pink-500" }, +}; + +function FlowNode({ icon, color, title, example, tooltipContent }: FlowNodeProps) { + const c = COLOR_MAP[color]; + const node: ReactNode = ( +
+ +

{title}

+

{example}

+
+ ); + + return tooltipContent ? ( + + {node} + + ) : ( + node + ); +} + +function FlowArrow({ label }: { label?: string }) { + return ( +
+ + {label && ( + {label} + )} +
+ ); +} + +export default function TranslateFlowDiagram() { + const t = useTranslations("translator"); + const tr = useCallback( + (key: string, fallback: string) => { + try { + const translated = t(key); + return translated === key || translated === `translator.${key}` ? fallback : translated; + } catch { + return fallback; + } + }, + [t], + ); + + return ( +
+ + + + + +
+ ); +} diff --git a/src/app/(dashboard)/dashboard/translator/components/TranslatorConceptCard.tsx b/src/app/(dashboard)/dashboard/translator/components/TranslatorConceptCard.tsx new file mode 100644 index 0000000000..c612788e2e --- /dev/null +++ b/src/app/(dashboard)/dashboard/translator/components/TranslatorConceptCard.tsx @@ -0,0 +1,79 @@ +"use client"; + +import { useCallback, useState } from "react"; +import { useTranslations } from "next-intl"; +import { Card } from "@/shared/components"; +import TranslateFlowDiagram from "./TranslateFlowDiagram"; + +export default function TranslatorConceptCard() { + const t = useTranslations("translator"); + const [open, setOpen] = useState(false); + + const tr = useCallback( + (key: string, fallback: string) => { + try { + const translated = t(key); + return translated === key || translated === `translator.${key}` ? fallback : translated; + } catch { + return fallback; + } + }, + [t], + ); + + return ( + +
+
+ +
+

+ {tr( + "conceptHeadline", + 'Sua app fala o "idioma" de uma API. O Translator converte para usar outro provider.', + )} +

+

+ {tr( + "friendlySubtitle", + "Use sua app existente com qualquer provider — sem reescrever código.", + )} +

+
+
+ + + + + + {open && ( +
+ {tr( + "conceptHowItWorksBody", + "Sua app envia um pedido no formato dela. O Translator detecta o formato, converte via OpenAI como hub intermediário (ou direto, quando há tradutor direto disponível), envia ao provider escolhido e devolve a resposta convertida de volta no formato da sua app.", + )} +
+ )} +
+
+ ); +} diff --git a/tests/unit/translator-friendly-concept-card.test.tsx b/tests/unit/translator-friendly-concept-card.test.tsx new file mode 100644 index 0000000000..44e1f5fc0d --- /dev/null +++ b/tests/unit/translator-friendly-concept-card.test.tsx @@ -0,0 +1,294 @@ +// @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"; + +// Minimal i18n stub — returns the key so tests can assert on fallback rendering +vi.mock("next-intl", () => ({ + useTranslations: () => (key: string) => key, +})); + +// Minimal shared component stubs — Card wraps children, Tooltip passes through +vi.mock("@/shared/components", () => ({ + Card: ({ + children, + className, + }: { + children: React.ReactNode; + className?: string; + }) =>
{children}
, +})); + +vi.mock("@/shared/components/Tooltip", () => ({ + default: ({ children }: { children: React.ReactNode }) => <>{children}, +})); + +const cleanupCallbacks: Array<() => void> = []; + +function makeContainer(): HTMLElement { + const container = document.createElement("div"); + document.body.appendChild(container); + cleanupCallbacks.push(() => { + container.remove(); + }); + return container; +} + +describe("TranslatorConceptCard", () => { + beforeEach(() => { + ( + globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean } + ).IS_REACT_ACT_ENVIRONMENT = true; + }); + + afterEach(() => { + while (cleanupCallbacks.length > 0) { + cleanupCallbacks.pop()?.(); + } + document.body.innerHTML = ""; + }); + + it("exports a default function component", async () => { + const mod = await import( + "@/app/(dashboard)/dashboard/translator/components/TranslatorConceptCard" + ); + expect(typeof mod.default).toBe("function"); + }); + + it("renders the card with info icon and headline", async () => { + const { default: TranslatorConceptCard } = await import( + "@/app/(dashboard)/dashboard/translator/components/TranslatorConceptCard" + ); + const container = makeContainer(); + const root = createRoot(container); + await act(async () => { + root.render(); + }); + // Card should be in the DOM + expect(container.querySelector("[data-testid='card']")).toBeTruthy(); + // Info icon should be present + const icons = container.querySelectorAll(".material-symbols-outlined"); + const iconTexts = Array.from(icons).map((el) => el.textContent?.trim()); + expect(iconTexts).toContain("info"); + }); + + it("renders the flow diagram with 3 FlowNode elements (app, source, target)", async () => { + const { default: TranslatorConceptCard } = await import( + "@/app/(dashboard)/dashboard/translator/components/TranslatorConceptCard" + ); + const container = makeContainer(); + const root = createRoot(container); + await act(async () => { + root.render(); + }); + // The diagram grid should contain 3 node icons (smart_toy, psychology, auto_awesome) + const icons = container.querySelectorAll(".material-symbols-outlined"); + const iconTexts = Array.from(icons).map((el) => el.textContent?.trim()); + expect(iconTexts).toContain("smart_toy"); + expect(iconTexts).toContain("psychology"); + expect(iconTexts).toContain("auto_awesome"); + }); + + it("renders the diagram with arrow_forward separators", async () => { + const { default: TranslatorConceptCard } = await import( + "@/app/(dashboard)/dashboard/translator/components/TranslatorConceptCard" + ); + const container = makeContainer(); + const root = createRoot(container); + await act(async () => { + root.render(); + }); + const icons = container.querySelectorAll(".material-symbols-outlined"); + const iconTexts = Array.from(icons).map((el) => el.textContent?.trim()); + // Two arrows between 3 nodes + expect(iconTexts.filter((t) => t === "arrow_forward").length).toBeGreaterThanOrEqual(2); + }); + + it("toggle button starts collapsed (aria-expanded=false)", async () => { + const { default: TranslatorConceptCard } = await import( + "@/app/(dashboard)/dashboard/translator/components/TranslatorConceptCard" + ); + const container = makeContainer(); + const root = createRoot(container); + await act(async () => { + root.render(); + }); + const toggleBtn = container.querySelector( + "button[aria-controls='translator-concept-how-it-works']", + ); + expect(toggleBtn).toBeTruthy(); + expect(toggleBtn?.getAttribute("aria-expanded")).toBe("false"); + // Collapsed panel should not be in the DOM yet + expect(container.querySelector("#translator-concept-how-it-works")).toBeNull(); + }); + + it("toggle expands 'Como funciona' section and sets aria-expanded=true", async () => { + const { default: TranslatorConceptCard } = await import( + "@/app/(dashboard)/dashboard/translator/components/TranslatorConceptCard" + ); + const container = makeContainer(); + const root = createRoot(container); + await act(async () => { + root.render(); + }); + const toggleBtn = container.querySelector( + "button[aria-controls='translator-concept-how-it-works']", + ) as HTMLButtonElement | null; + expect(toggleBtn).toBeTruthy(); + + // Click to expand + await act(async () => { + toggleBtn?.click(); + }); + + expect(toggleBtn?.getAttribute("aria-expanded")).toBe("true"); + const panel = container.querySelector("#translator-concept-how-it-works"); + expect(panel).toBeTruthy(); + }); + + it("toggle collapses section on second click and restores aria-expanded=false", async () => { + const { default: TranslatorConceptCard } = await import( + "@/app/(dashboard)/dashboard/translator/components/TranslatorConceptCard" + ); + const container = makeContainer(); + const root = createRoot(container); + await act(async () => { + root.render(); + }); + const toggleBtn = container.querySelector( + "button[aria-controls='translator-concept-how-it-works']", + ) as HTMLButtonElement | null; + + // Expand + await act(async () => { + toggleBtn?.click(); + }); + expect(toggleBtn?.getAttribute("aria-expanded")).toBe("true"); + + // Collapse + await act(async () => { + toggleBtn?.click(); + }); + expect(toggleBtn?.getAttribute("aria-expanded")).toBe("false"); + expect(container.querySelector("#translator-concept-how-it-works")).toBeNull(); + }); + + it("toggle button icon changes between expand_more and expand_less", async () => { + const { default: TranslatorConceptCard } = await import( + "@/app/(dashboard)/dashboard/translator/components/TranslatorConceptCard" + ); + const container = makeContainer(); + const root = createRoot(container); + await act(async () => { + root.render(); + }); + + const toggleBtn = container.querySelector( + "button[aria-controls='translator-concept-how-it-works']", + ) as HTMLButtonElement | null; + + // Initially collapsed: should show expand_more + const btnIcons = toggleBtn?.querySelectorAll(".material-symbols-outlined"); + const btnIconTexts = Array.from(btnIcons ?? []).map((el) => el.textContent?.trim()); + expect(btnIconTexts).toContain("expand_more"); + expect(btnIconTexts).not.toContain("expand_less"); + + // After click: should show expand_less + await act(async () => { + toggleBtn?.click(); + }); + const btnIconsAfter = toggleBtn?.querySelectorAll(".material-symbols-outlined"); + const btnIconTextsAfter = Array.from(btnIconsAfter ?? []).map((el) => el.textContent?.trim()); + expect(btnIconTextsAfter).toContain("expand_less"); + expect(btnIconTextsAfter).not.toContain("expand_more"); + }); +}); + +describe("TranslateFlowDiagram", () => { + beforeEach(() => { + ( + globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean } + ).IS_REACT_ACT_ENVIRONMENT = true; + }); + + afterEach(() => { + while (cleanupCallbacks.length > 0) { + cleanupCallbacks.pop()?.(); + } + document.body.innerHTML = ""; + }); + + it("exports a default function component", async () => { + const mod = await import( + "@/app/(dashboard)/dashboard/translator/components/TranslateFlowDiagram" + ); + expect(typeof mod.default).toBe("function"); + }); + + it("renders all 3 flow node icons", async () => { + const { default: TranslateFlowDiagram } = await import( + "@/app/(dashboard)/dashboard/translator/components/TranslateFlowDiagram" + ); + const container = makeContainer(); + const root = createRoot(container); + await act(async () => { + root.render(); + }); + const icons = container.querySelectorAll(".material-symbols-outlined"); + const iconTexts = Array.from(icons).map((el) => el.textContent?.trim()); + expect(iconTexts).toContain("smart_toy"); + expect(iconTexts).toContain("psychology"); + expect(iconTexts).toContain("auto_awesome"); + }); + + it("renders exactly 2 arrow_forward separators between nodes", async () => { + const { default: TranslateFlowDiagram } = await import( + "@/app/(dashboard)/dashboard/translator/components/TranslateFlowDiagram" + ); + const container = makeContainer(); + const root = createRoot(container); + await act(async () => { + root.render(); + }); + const icons = container.querySelectorAll(".material-symbols-outlined"); + const iconTexts = Array.from(icons).map((el) => el.textContent?.trim()); + expect(iconTexts.filter((t) => t === "arrow_forward")).toHaveLength(2); + }); + + it("renders a responsive grid container", async () => { + const { default: TranslateFlowDiagram } = await import( + "@/app/(dashboard)/dashboard/translator/components/TranslateFlowDiagram" + ); + const container = makeContainer(); + const root = createRoot(container); + await act(async () => { + root.render(); + }); + // The grid wrapper should have grid class and responsive columns + const grid = container.querySelector(".grid"); + expect(grid).toBeTruthy(); + // Responsive class for sm breakpoint + expect(grid?.className).toContain("sm:grid-cols-"); + }); + + it("i18n fallback: renders labels using fallback strings when translations return keys", async () => { + const { default: TranslateFlowDiagram } = await import( + "@/app/(dashboard)/dashboard/translator/components/TranslateFlowDiagram" + ); + const container = makeContainer(); + const root = createRoot(container); + await act(async () => { + root.render(); + }); + // When mock returns key, tr() detects key === translation and uses fallback + // The fallback text should appear in the DOM + const text = container.textContent ?? ""; + expect(text).toContain("Sua app"); + expect(text).toContain("ex: SDK Anthropic"); + expect(text).toContain("Formato origem"); + expect(text).toContain("claude"); + expect(text).toContain("Provider destino"); + expect(text).toContain("Gemini"); + }); +});