mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-31 04:12:10 +03:00
Merge branch 'feat/translator-friendly-concept-card-F2' into refactor/pages-v3-19-translator-friendly-redesign
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
294
tests/unit/translator-friendly-concept-card.test.tsx
Normal file
294
tests/unit/translator-friendly-concept-card.test.tsx
Normal 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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user