Merge branch 'feat/translator-friendly-concept-card-F2' into refactor/pages-v3-19-translator-friendly-redesign

This commit is contained in:
diegosouzapw
2026-05-27 19:56:38 -03:00
3 changed files with 495 additions and 0 deletions

View File

@@ -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 = (
<div
className={`flex flex-col items-center gap-1 rounded-lg border ${c.border} ${c.bg} px-3 py-2 text-center min-w-0`}
>
<span className={`material-symbols-outlined text-[20px] ${c.text}`} aria-hidden="true">
{icon}
</span>
<p className="text-[11px] font-semibold text-text-main leading-tight">{title}</p>
<p className="text-[10px] text-text-muted leading-tight">{example}</p>
</div>
);
return tooltipContent ? (
<Tooltip content={tooltipContent} position="top" multiline>
{node}
</Tooltip>
) : (
node
);
}
function FlowArrow({ label }: { label?: string }) {
return (
<div className="flex flex-col items-center justify-center text-text-muted">
<span
className="material-symbols-outlined text-[20px] rotate-90 sm:rotate-0"
aria-hidden="true"
>
arrow_forward
</span>
{label && (
<span className="text-[9px] uppercase tracking-wide mt-0.5">{label}</span>
)}
</div>
);
}
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 (
<div className="grid grid-cols-1 sm:grid-cols-[1fr_auto_1fr_auto_1fr] gap-2 sm:gap-3 sm:items-stretch">
<FlowNode
icon="smart_toy"
color="primary"
title={tr("conceptDiagramAppLabel", "Sua app")}
example={tr("conceptDiagramExampleApp", "ex: SDK Anthropic")}
/>
<FlowArrow label={tr("conceptDiagramArrow1", "fala")} />
<FlowNode
icon="psychology"
color="orange"
title={tr("conceptDiagramSourceLabel", "Formato origem")}
example={tr("conceptDiagramExampleSource", "claude")}
tooltipContent={tr(
"conceptDiagramSourceTooltip",
"Formato do protocolo de API que sua app fala (ex: Anthropic Messages, OpenAI Chat Completions, Gemini).",
)}
/>
<FlowArrow label={tr("conceptDiagramArrow2", "Translator")} />
<FlowNode
icon="auto_awesome"
color="blue"
title={tr("conceptDiagramTargetLabel", "Provider destino")}
example={tr("conceptDiagramExampleTarget", "Gemini")}
tooltipContent={tr(
"conceptDiagramTargetTooltip",
"Provider conectado em OmniRoute que vai responder de verdade (ex: Google Gemini, Anthropic, etc).",
)}
/>
</div>
);
}

View File

@@ -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 (
<Card className="border-primary/10 bg-primary/5">
<div className="p-4 space-y-3">
<div className="flex items-start gap-3">
<span
className="material-symbols-outlined text-primary text-[22px] mt-0.5 shrink-0"
aria-hidden="true"
>
info
</span>
<div className="flex-1 min-w-0">
<h2 className="text-sm font-semibold text-text-main mb-1">
{tr(
"conceptHeadline",
'Sua app fala o "idioma" de uma API. O Translator converte para usar outro provider.',
)}
</h2>
<p className="text-xs text-text-muted">
{tr(
"friendlySubtitle",
"Use sua app existente com qualquer provider — sem reescrever código.",
)}
</p>
</div>
</div>
<TranslateFlowDiagram />
<button
type="button"
onClick={() => setOpen((v) => !v)}
aria-expanded={open}
aria-controls="translator-concept-how-it-works"
className="flex items-center gap-2 text-xs font-medium text-primary hover:text-primary/80 transition-colors w-full justify-start py-1 rounded"
>
<span>{tr("conceptHowItWorksToggle", "Como funciona")}</span>
<span className="material-symbols-outlined text-[16px]" aria-hidden="true">
{open ? "expand_less" : "expand_more"}
</span>
</button>
{open && (
<div
id="translator-concept-how-it-works"
className="text-xs text-text-muted leading-relaxed border-t border-border pt-3"
>
{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.",
)}
</div>
)}
</div>
</Card>
);
}

View File

@@ -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;
}) => <div data-testid="card" className={className}>{children}</div>,
}));
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(<TranslatorConceptCard />);
});
// 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(<TranslatorConceptCard />);
});
// 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(<TranslatorConceptCard />);
});
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(<TranslatorConceptCard />);
});
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(<TranslatorConceptCard />);
});
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(<TranslatorConceptCard />);
});
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(<TranslatorConceptCard />);
});
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(<TranslateFlowDiagram />);
});
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(<TranslateFlowDiagram />);
});
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(<TranslateFlowDiagram />);
});
// 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(<TranslateFlowDiagram />);
});
// 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");
});
});