diff --git a/ROADMAP.md b/ROADMAP.md
index edc8b12..887ad0c 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -60,7 +60,7 @@ Estado atual, próximas fases e visão de longo prazo do projeto.
| **Hamburger menu (mobile)** — header limpo com alerta urgência + bottom sheet de ações | ✅ |
| **Mobile Pipeline compacto** — busca + sort inline, chips menores, mais espaço para cards | ✅ |
| **Desktop hover actions** — arquivar/deletar aparecem ao hover no card sem precisar clicar+segurar | ✅ |
-| Suite de testes — 255 testes (unit + component + integration), CI bloqueia build em falha | ✅ |
+| Suite de testes — 187 testes (unit + component + integration), CI bloqueia build em falha | ✅ |
| `.env.example` com todas as variáveis documentadas | ✅ |
### Infraestrutura
@@ -272,3 +272,7 @@ Em produção Componentizado IA Avançada Plataforma
| `VagaTab` — dados da vaga + próxima etapa com auto-stage + notas, substituindo OverviewTab | ✅ |
| Cards compactos sem preview de nota | ✅ |
| Arquivos obsoletos removidos (TimelineTab, AITab, OverviewTab, MessagesTab) | ✅ |
+| **VagaTab accordion** — cards Próxima etapa / Dados da vaga / Anotações com padrão visual uniforme e abre/fecha | ✅ |
+| **InlineTags compact** — input "+ tag" compacto e centralizado, sem corte no mobile | ✅ |
+| **Desktop hover actions** — botão arquivar/deletar aparece no hover do card, sem sobreposição no Badge | ✅ |
+| **Remoção de código morto** — NewProcessModal, ImportModal, ImportChatGPTModal, RecruiterMessageModal, importHelpers, isUrgent (-1847 linhas) | ✅ |
diff --git a/TESTING.md b/TESTING.md
index 8edbdd2..64c9f7e 100644
--- a/TESTING.md
+++ b/TESTING.md
@@ -4,14 +4,14 @@
## 1. Estado atual
-**255 testes passando. Zero falhas.**
+**187 testes passando. Zero falhas.**
| Camada | Arquivos | Testes |
|---|---|---|
-| Unit | 9 arquivos | ~197 |
-| Component | 6 arquivos | ~58 |
-| Integration | 1 arquivo | — |
-| **Total** | **17 arquivos** | **255** |
+| Unit | 7 arquivos | ~155 |
+| Component | 7 arquivos | ~22 |
+| Integration | 1 arquivo | ~10 |
+| **Total** | **15 arquivos** | **187** |
**CI:** `npm run test:run` executa antes de `npm run build` em todo PR e push para `main`. Build só acontece se todos os testes passarem.
@@ -34,29 +34,31 @@
```
src/__tests__/
├── unit/
-│ ├── buildPrompt.test.js # buildCVPrompt — montagem de prompt de adaptação de CV
-│ ├── channel.test.js # CONTACT_CHANNELS — valores e ícones
-│ ├── constants.test.js # STAGE, ACTIVE_STAGES, CHANNELS, SCENARIOS
-│ ├── dateUtils.test.js # fmtDate, daysDiff — formatação e diff de datas
-│ ├── edgeFunction.test.js # anthropic-proxy — rate limit, CORS, validação de payload
+│ ├── buildPrompt.test.js # buildCVPrompt — montagem de prompt de adaptação de CV
+│ ├── channel.test.js # CONTACT_CHANNELS — valores e ícones
+│ ├── constants.test.js # STAGE, ACTIVE_STAGES, CHANNELS, SCENARIOS
+│ ├── dateUtils.test.js # fmtDate, daysDiff — formatação e diff de datas
+│ ├── edgeFunction.test.js # anthropic-proxy — rate limit, CORS, validação de payload
│ ├── extractTextFromPdf.test.js # extractTextFromPdf — extração de texto de PDFs
-│ ├── filterProcesses.test.js # filterProcesses — busca e filtro por stage
-│ ├── importHelpers.test.js # parseCSV, normalizeProcess, detectFormat
-│ ├── sort.test.js # sortProcesses — urgência, empresa, stage, recente
-│ └── supabase.test.js # rowToProcess, processToRow — mapeadores snake_case ↔ camelCase
+│ ├── filterProcesses.test.js # filterProcesses — busca e filtro por stage
+│ ├── sort.test.js # sortProcesses — urgência, empresa, stage, recente
+│ └── supabase.test.js # rowToProcess, processToRow — mapeadores snake_case ↔ camelCase
│
├── components/
│ ├── CVTab.test.jsx # CVTab — fluxo 4 etapas (input → analyzing → review → result)
│ ├── InlineTags.test.jsx # InlineTags — adicionar, remover, tecla Enter/Escape
│ ├── ProcessCard.test.jsx # ProcessCard — render, urgência, swipe mobile, ações
│ ├── ProfileSetupModal.test.jsx # ProfileSetupModal — abas, salvar, upload PDF
-│ ├── RecruiterMessageModal.test.jsx # RecruiterMessageModal — fluxo 3 etapas
│ └── ResumesModal.test.jsx # ResumesModal — listagem, criar, editar, excluir
│
└── integration/
└── resumes.test.js # useResumes hook — CRUD completo com Supabase mockado (MSW)
```
+**Removidos nesta sessão:**
+- `importHelpers.test.js` — removido junto com `importHelpers.js` (código morto)
+- `RecruiterMessageModal.test.jsx` — removido junto com `RecruiterMessageModal.jsx` (código morto)
+
---
## 4. Regra: testes com toda nova funcionalidade
@@ -164,5 +166,6 @@ Todo push para `main` e todo PR disparam esse pipeline. O deploy na Vercel só a
| Data | Testes | Falhas | Observação |
|---|---|---|---|
+| 2026-06-07 | 187 | 0 | Remoção de código morto: ImportModal, ImportChatGPTModal, NewProcessModal, RecruiterMessageModal, importHelpers, isUrgent; VagaTab accordion; InlineTags compact |
| 2026-05-30 | 253 | 0 | Correção: constants.test (ACTIVE_STAGES 4 itens), ProfileSetupModal (mock extractTextFromPdf, label aba "CV") |
| Sessão anterior | 252 | 8 | ACTIVE_STAGES esperava 5 itens; ProfileSetupModal mockava pdfjs diretamente (não funcionava com lazy import) |
diff --git a/public/screenshot.png b/public/screenshot.png
new file mode 100644
index 0000000..61d0111
Binary files /dev/null and b/public/screenshot.png differ
diff --git a/src/__tests__/components/RecruiterMessageModal.test.jsx b/src/__tests__/components/RecruiterMessageModal.test.jsx
deleted file mode 100644
index 4655726..0000000
--- a/src/__tests__/components/RecruiterMessageModal.test.jsx
+++ /dev/null
@@ -1,218 +0,0 @@
-import { describe, it, expect, vi, beforeEach } from "vitest";
-import { render, screen, waitFor } from "@testing-library/react";
-import userEvent from "@testing-library/user-event";
-import { RecruiterMessageModal } from "../../components/modals/RecruiterMessageModal.jsx";
-
-// ─── Mocks ────────────────────────────────────────────────────────────────────
-vi.mock("../../supabase.js", () => ({
- supabase: {
- auth: {
- getSession: vi.fn().mockResolvedValue({
- data: { session: { access_token: "mock-token" } },
- }),
- },
- },
-}));
-
-const mockCallAI = vi.fn();
-vi.mock("../../lib/ai.js", () => ({
- callAI: (...args) => mockCallAI(...args),
-}));
-
-// ─── Fixtures ─────────────────────────────────────────────────────────────────
-const mockExtracted = JSON.stringify({
- recruiter: "Ana Lima",
- recruiterRole: "Tech Recruiter",
- company: "Nubank",
- role: "Senior Frontend Engineer",
- stack: "React, TypeScript, GraphQL",
- regime: "remoto",
- salary: "R$ 25k-30k",
- nextStep: "Call de 30min",
- location: "São Paulo, SP",
-});
-
-const mockDraftText = "Olá Ana, obrigado pelo contato! Tenho interesse em conhecer melhor a oportunidade na Nubank.";
-
-const defaultProps = {
- onClose: vi.fn(),
- onProcessCreated: vi.fn(),
-};
-
-beforeEach(() => {
- vi.clearAllMocks();
- Object.assign(navigator, {
- clipboard: { writeText: vi.fn().mockResolvedValue(undefined) },
- });
-});
-
-// ─── Helpers ─────────────────────────────────────────────────────────────────
-const setupToResult = async () => {
- mockCallAI
- .mockResolvedValueOnce(mockExtracted) // extraction call
- .mockResolvedValueOnce(mockDraftText); // draft call (plain text)
-
- render();
- await userEvent.type(screen.getByTestId("msg-input"), "Mensagem de recrutador da Nubank");
- await userEvent.click(screen.getByTestId("btn-analyze"));
-
- // Wait for result step to appear (extraction done)
- await waitFor(() => screen.getByDisplayValue("Nubank"));
- // Wait for draft to finish loading
- await waitFor(() => screen.getByTestId("draft-output"));
-};
-
-// ─── Tests ───────────────────────────────────────────────────────────────────
-describe("RecruiterMessageModal — step paste", () => {
- it("renderiza textarea para colar mensagem", () => {
- render();
- expect(screen.getByTestId("msg-input")).toBeDefined();
- expect(screen.getByPlaceholderText(/Cole a mensagem aqui/i)).toBeDefined();
- });
-
- it("botão Analisar desabilitado quando mensagem vazia", () => {
- render();
- expect(screen.getByTestId("btn-analyze").disabled).toBe(true);
- });
-
- it("botão Analisar habilitado após digitar mensagem", async () => {
- render();
- await userEvent.type(screen.getByTestId("msg-input"), "Olá Fernando");
- expect(screen.getByTestId("btn-analyze").disabled).toBe(false);
- });
-
- it("initialMsg pré-preenche o textarea", () => {
- render();
- expect(screen.getByDisplayValue("Mensagem pré-preenchida")).toBeDefined();
- });
-
- it("botão Analisar habilitado quando initialMsg presente", () => {
- render();
- expect(screen.getByTestId("btn-analyze").disabled).toBe(false);
- });
-
- it("clicar no X chama onClose", async () => {
- render();
- await userEvent.click(screen.getByTestId("btn-close"));
- expect(defaultProps.onClose).toHaveBeenCalledOnce();
- });
-});
-
-describe("RecruiterMessageModal — extração via IA", () => {
- it("exibe spinner no step working", async () => {
- // Mock that never resolves so we can check the working state
- mockCallAI.mockImplementation(() => new Promise(() => {}));
- render();
- await userEvent.type(screen.getByTestId("msg-input"), "Mensagem");
- await userEvent.click(screen.getByTestId("btn-analyze"));
- await waitFor(() => {
- expect(screen.getByText(/Extraindo informações/i)).toBeDefined();
- });
- });
-
- it("após extração exibe campos company e role preenchidos", async () => {
- mockCallAI
- .mockResolvedValueOnce(mockExtracted)
- .mockResolvedValue(mockDraftText);
- render();
- await userEvent.type(screen.getByTestId("msg-input"), "Mensagem");
- await userEvent.click(screen.getByTestId("btn-analyze"));
- await waitFor(() => screen.getByDisplayValue("Nubank"));
- expect(screen.getByDisplayValue("Senior Frontend Engineer")).toBeDefined();
- });
-
- it("exibe erro quando extração falha e volta ao paste", async () => {
- mockCallAI.mockRejectedValue(new Error("API error"));
- render();
- await userEvent.type(screen.getByTestId("msg-input"), "Mensagem");
- await userEvent.click(screen.getByTestId("btn-analyze"));
- await waitFor(() => {
- expect(screen.getByText(/Não foi possível extrair/i)).toBeDefined();
- });
- // Should be back on paste step
- expect(screen.getByTestId("msg-input")).toBeDefined();
- });
-});
-
-describe("RecruiterMessageModal — step result", () => {
- it("campos extraídos são editáveis", async () => {
- await setupToResult();
- const companyInput = screen.getByTestId("field-company");
- await userEvent.clear(companyInput);
- await userEvent.type(companyInput, "Stone");
- expect(screen.getByDisplayValue("Stone")).toBeDefined();
- });
-
- it("exibe rascunho de resposta em plain text", async () => {
- await setupToResult();
- expect(screen.getByDisplayValue(mockDraftText)).toBeDefined();
- });
-
- it("botão Copiar chama clipboard com texto do draft", async () => {
- await setupToResult();
- await userEvent.click(screen.getByTestId("btn-copy"));
- await waitFor(() => {
- expect(navigator.clipboard.writeText).toHaveBeenCalledWith(mockDraftText);
- });
- });
-
- it("botão Salvar chama onProcessCreated com dados corretos", async () => {
- await setupToResult();
- await userEvent.click(screen.getByTestId("btn-save"));
- expect(defaultProps.onProcessCreated).toHaveBeenCalledOnce();
- const created = defaultProps.onProcessCreated.mock.calls[0][0];
- expect(created.company).toBe("Nubank");
- expect(created.stage).toBe("contacted");
- expect(created.origin).toBe("inbound");
- expect(created.channel).toBe("linkedin");
- });
-
- it("tags criadas a partir da stack extraída", async () => {
- await setupToResult();
- await userEvent.click(screen.getByTestId("btn-save"));
- const created = defaultProps.onProcessCreated.mock.calls[0][0];
- expect(created.tags).toContain("React");
- expect(created.tags).toContain("TypeScript");
- });
-
- it("notas incluem mensagem original", async () => {
- await setupToResult();
- await userEvent.click(screen.getByTestId("btn-save"));
- const created = defaultProps.onProcessCreated.mock.calls[0][0];
- expect(created.notes).toContain("Mensagem de recrutador da Nubank");
- });
-
- it("após salvar botão muda para 'Abrir processo' e chama onClose ao clicar", async () => {
- await setupToResult();
- await userEvent.click(screen.getByTestId("btn-save"));
- await waitFor(() => {
- expect(screen.getByText(/Abrir processo/i)).toBeDefined();
- });
- await userEvent.click(screen.getByTestId("btn-save"));
- expect(defaultProps.onClose).toHaveBeenCalledOnce();
- });
-
- it("botão Voltar retorna ao step paste", async () => {
- await setupToResult();
- await userEvent.click(screen.getByTestId("btn-back"));
- expect(screen.getByTestId("msg-input")).toBeDefined();
- });
-});
-
-describe("RecruiterMessageModal — sem draft", () => {
- it("exibe mensagem de fallback quando draft falha", async () => {
- mockCallAI
- .mockResolvedValueOnce(mockExtracted) // extraction ok
- .mockRejectedValueOnce(new Error("draft error")); // draft fails
-
- render();
- await userEvent.type(screen.getByTestId("msg-input"), "Mensagem");
- await userEvent.click(screen.getByTestId("btn-analyze"));
- await waitFor(() => screen.getByDisplayValue("Nubank"));
- await waitFor(() => {
- expect(screen.getByText(/Não foi possível gerar a resposta/i)).toBeDefined();
- });
- // Save button still appears
- expect(screen.getByTestId("btn-save")).toBeDefined();
- });
-});
diff --git a/src/__tests__/unit/dateUtils.test.js b/src/__tests__/unit/dateUtils.test.js
index 3efab28..e11077d 100644
--- a/src/__tests__/unit/dateUtils.test.js
+++ b/src/__tests__/unit/dateUtils.test.js
@@ -1,5 +1,5 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
-import { fmtDate, daysDiff, isUrgent } from "../../utils/dateUtils.js";
+import { fmtDate, daysDiff } from "../../utils/dateUtils.js";
describe("fmtDate", () => {
it("formata data válida em pt-BR", () => {
@@ -68,21 +68,3 @@ describe("daysDiff", () => {
expect(daysDiff("2026-06-20")).toBeGreaterThan(0);
});
});
-
-describe("isUrgent", () => {
- beforeEach(() => {
- vi.useFakeTimers();
- vi.setSystemTime(new Date("2026-05-20T12:00:00"));
- });
-
- afterEach(() => {
- vi.useRealTimers();
- });
-
- it("null não é urgente", () => expect(isUrgent(null)).toBe(false));
- it("hoje (diff=0) é urgente", () => expect(isUrgent("2026-05-20")).toBe(true));
- it("amanhã (diff=1) é urgente", () => expect(isUrgent("2026-05-21")).toBe(true));
- it("depois de amanhã (diff=2) é urgente", () => expect(isUrgent("2026-05-22")).toBe(true));
- it("3 dias (diff=3) não é urgente", () => expect(isUrgent("2026-05-23")).toBe(false));
- it("ontem (diff=-1) não é urgente", () => expect(isUrgent("2026-05-19")).toBe(false));
-});
diff --git a/src/__tests__/unit/importHelpers.test.js b/src/__tests__/unit/importHelpers.test.js
deleted file mode 100644
index 1ad5bb9..0000000
--- a/src/__tests__/unit/importHelpers.test.js
+++ /dev/null
@@ -1,251 +0,0 @@
-import { describe, it, expect } from "vitest";
-import {
- isChatGPTFormat,
- isICCFormat,
- looksLikeRecruitment,
- parseCSV,
- normalizeProcess,
-} from "../../utils/importHelpers.js";
-
-// ── isChatGPTFormat ──────────────────────────────────────────────────────────
-
-describe("isChatGPTFormat", () => {
- it("retorna true para array com campo mapping", () => {
- expect(isChatGPTFormat([{ mapping: {}, title: "Conversa" }])).toBe(true);
- });
-
- it("retorna false para array vazio", () => {
- expect(isChatGPTFormat([])).toBe(false);
- });
-
- it("retorna false para array sem mapping", () => {
- expect(isChatGPTFormat([{ company: "Nubank", role: "Dev" }])).toBe(false);
- });
-
- it("retorna false para não-array", () => {
- expect(isChatGPTFormat(null)).toBe(false);
- expect(isChatGPTFormat("string")).toBe(false);
- expect(isChatGPTFormat({ mapping: {} })).toBe(false);
- });
-
- it("retorna false para objeto isolado com mapping (não array)", () => {
- expect(isChatGPTFormat({ mapping: {} })).toBe(false);
- });
-});
-
-// ── isICCFormat ──────────────────────────────────────────────────────────────
-
-describe("isICCFormat", () => {
- it("retorna true para array com campo company", () => {
- expect(isICCFormat([{ company: "Nubank", role: "Dev", stage: "contacted" }])).toBe(true);
- });
-
- it("retorna false para array vazio", () => {
- expect(isICCFormat([])).toBe(false);
- });
-
- it("retorna false para array sem company", () => {
- expect(isICCFormat([{ mapping: {} }])).toBe(false);
- });
-
- it("retorna false para não-array", () => {
- expect(isICCFormat(null)).toBe(false);
- expect(isICCFormat("string")).toBe(false);
- });
-});
-
-// ── looksLikeRecruitment ─────────────────────────────────────────────────────
-
-describe("looksLikeRecruitment", () => {
- it("detecta 'recrutador' no título", () => {
- expect(looksLikeRecruitment({ title: "Conversa com recrutador" })).toBe(true);
- });
-
- it("detecta 'job' (inglês)", () => {
- expect(looksLikeRecruitment({ title: "Senior job offer" })).toBe(true);
- });
-
- it("detecta 'vaga' (português)", () => {
- expect(looksLikeRecruitment({ title: "Vaga de Dev React" })).toBe(true);
- });
-
- it("detecta 'interview'", () => {
- expect(looksLikeRecruitment({ title: "Technical interview at Nubank" })).toBe(true);
- });
-
- it("detecta 'desenvolvedor'", () => {
- expect(looksLikeRecruitment({ title: "Oportunidade para desenvolvedor" })).toBe(true);
- });
-
- it("retorna false para conversa genérica", () => {
- expect(looksLikeRecruitment({ title: "Como fazer pizza margherita" })).toBe(false);
- });
-
- it("é case-insensitive", () => {
- expect(looksLikeRecruitment({ title: "RECRUTADOR SENIOR" })).toBe(true);
- });
-
- it("retorna false para título vazio", () => {
- expect(looksLikeRecruitment({ title: "" })).toBe(false);
- });
-
- it("retorna false quando título é undefined", () => {
- expect(looksLikeRecruitment({})).toBe(false);
- });
-});
-
-// ── parseCSV ─────────────────────────────────────────────────────────────────
-
-describe("parseCSV", () => {
- it("parseia CSV simples com cabeçalho", () => {
- const csv = `company,role,stage\nNubank,Senior FE,interview`;
- const result = parseCSV(csv);
- expect(result).toHaveLength(1);
- expect(result[0].company).toBe("Nubank");
- expect(result[0].role).toBe("Senior FE");
- expect(result[0].stage).toBe("interview");
- });
-
- it("parseia múltiplas linhas", () => {
- const csv = `company,role\nNubank,Dev\nSpotify,SWE\nStone,Tech Lead`;
- expect(parseCSV(csv)).toHaveLength(3);
- });
-
- it("filtra linhas sem company", () => {
- const csv = `company,role\nNubank,Dev\n,SemEmpresa`;
- expect(parseCSV(csv)).toHaveLength(1);
- });
-
- it("aceita campo 'empresa' (português)", () => {
- const csv = `empresa,role\nNubank,Dev`;
- const result = parseCSV(csv);
- expect(result).toHaveLength(1);
- expect(result[0].empresa).toBe("Nubank");
- });
-
- it("retorna array vazio para CSV sem dados (só cabeçalho)", () => {
- expect(parseCSV("company,role")).toHaveLength(0);
- });
-
- it("retorna array vazio para texto vazio", () => {
- expect(parseCSV("")).toHaveLength(0);
- });
-
- it("retorna array vazio para uma única linha sem cabeçalho", () => {
- expect(parseCSV("somente uma linha")).toHaveLength(0);
- });
-
- it("remove aspas duplas dos valores", () => {
- const csv = `company,role\n"Nubank","Senior FE"`;
- const result = parseCSV(csv);
- expect(result[0].company).toBe("Nubank");
- expect(result[0].role).toBe("Senior FE");
- });
-
- it("converte cabeçalhos para minúsculas", () => {
- const csv = `Company,Role\nNubank,Dev`;
- const result = parseCSV(csv);
- expect(result[0]).toHaveProperty("company");
- expect(result[0]).toHaveProperty("role");
- });
-
- it("suporta quebra de linha Windows (\\r\\n)", () => {
- const csv = "company,role\r\nNubank,Dev\r\nSpotify,SWE";
- expect(parseCSV(csv)).toHaveLength(2);
- });
-
- it("campos ausentes ficam como string vazia", () => {
- const csv = `company,role,stage\nNubank,Dev`;
- const result = parseCSV(csv);
- expect(result[0].stage).toBe("");
- });
-});
-
-// ── normalizeProcess ─────────────────────────────────────────────────────────
-
-describe("normalizeProcess", () => {
- it("preserva campos válidos do processo ICC", () => {
- const input = {
- id: "test-id",
- company: "Nubank",
- role: "Senior FE",
- stage: "interview",
- origin: "inbound",
- location: "Remoto",
- salary: "R$ 25k",
- recruiter: "Ana",
- recruiterEmail: "ana@nu.com",
- contactedDate: "2026-05-01",
- notes: "Ótima vaga",
- tags: ["react", "typescript"],
- };
- const result = normalizeProcess(input);
- expect(result.id).toBe("test-id");
- expect(result.company).toBe("Nubank");
- expect(result.role).toBe("Senior FE");
- expect(result.stage).toBe("interview");
- expect(result.origin).toBe("inbound");
- expect(result.tags).toEqual(["react", "typescript"]);
- });
-
- it("stage inválido cai para 'contacted'", () => {
- expect(normalizeProcess({ company: "X", stage: "nao_existe" }).stage).toBe("contacted");
- });
-
- it("origin 'outbound' preservado", () => {
- expect(normalizeProcess({ company: "X", origin: "outbound" }).origin).toBe("outbound");
- });
-
- it("origin desconhecido → 'inbound'", () => {
- expect(normalizeProcess({ company: "X", origin: "qualquer" }).origin).toBe("inbound");
- });
-
- it("fallback de company para 'Empresa?'", () => {
- expect(normalizeProcess({}).company).toBe("Empresa?");
- });
-
- it("fallback de role para 'Cargo?'", () => {
- expect(normalizeProcess({}).role).toBe("Cargo?");
- });
-
- it("aceita alias português 'empresa'", () => {
- expect(normalizeProcess({ empresa: "Stone" }).company).toBe("Stone");
- });
-
- it("aceita alias português 'cargo'", () => {
- expect(normalizeProcess({ cargo: "Tech Lead" }).role).toBe("Tech Lead");
- });
-
- it("tags como string separada por ; vira array", () => {
- const result = normalizeProcess({ company: "X", tags: "react;node;typescript" });
- expect(result.tags).toEqual(["react", "node", "typescript"]);
- });
-
- it("tags array mantido como array", () => {
- const result = normalizeProcess({ company: "X", tags: ["react", "node"] });
- expect(result.tags).toEqual(["react", "node"]);
- });
-
- it("sem tags → array vazio", () => {
- expect(normalizeProcess({ company: "X" }).tags).toEqual([]);
- });
-
- it("gera id quando ausente", () => {
- const result = normalizeProcess({ company: "X" });
- expect(typeof result.id).toBe("string");
- expect(result.id.length).toBeGreaterThan(0);
- });
-
- it("recruiterEmail aceita alias 'email'", () => {
- const result = normalizeProcess({ company: "X", email: "teste@email.com" });
- expect(result.recruiterEmail).toBe("teste@email.com");
- });
-
- it("convTitle null quando ausente", () => {
- expect(normalizeProcess({ company: "X" }).convTitle).toBeNull();
- });
-
- it("convTitle preservado quando presente", () => {
- expect(normalizeProcess({ company: "X", convTitle: "Conversa ChatGPT" }).convTitle).toBe("Conversa ChatGPT");
- });
-});
diff --git a/src/components/auth/LoginScreen.jsx b/src/components/auth/LoginScreen.jsx
index a6633ff..d28c392 100644
--- a/src/components/auth/LoginScreen.jsx
+++ b/src/components/auth/LoginScreen.jsx
@@ -4,24 +4,87 @@ import { T } from "../../constants/index.js";
import Ic from "../ui/Ic.jsx";
import Btn from "../ui/Btn.jsx";
+const STRINGS = {
+ en: {
+ tagline: "Command Center",
+ signIn: "Sign in",
+ signInSub: "Enter your email and password to access.",
+ emailLabel: "Email",
+ emailPlaceholder: "you@example.com",
+ passwordLabel: "Password",
+ forgotLink: "Forgot password?",
+ signingIn: "Signing in…",
+ signInBtn: "Sign in",
+ magicLink: "Sign in without password",
+ magicTitle: "Magic link",
+ magicSub: "We'll send a sign-in link to your email.",
+ sendLink: "Send link",
+ sending: "Sending…",
+ backToPassword: "Back to password login",
+ forgotTitle: "Reset password",
+ forgotSub: "We'll send you a link to create a new password.",
+ sendReset: "Send reset email",
+ sentMagicTitle: "Link sent!",
+ sentMagicSub: "Check your inbox at",
+ sentForgotTitle: "Email sent!",
+ sentForgotSub: "Check the recovery instructions at",
+ backToLogin: "Back to login",
+ demo: "Try a demo without signing up →",
+ invalidCreds: "Invalid email or password.",
+ langToggle: "Português",
+ },
+ pt: {
+ tagline: "Command Center",
+ signIn: "Entrar",
+ signInSub: "Use seu e-mail e senha para acessar.",
+ emailLabel: "E-mail",
+ emailPlaceholder: "seu@email.com",
+ passwordLabel: "Senha",
+ forgotLink: "Esqueci minha senha",
+ signingIn: "Entrando…",
+ signInBtn: "Entrar",
+ magicLink: "Entrar sem senha (link mágico)",
+ magicTitle: "Link mágico",
+ magicSub: "Receba um link de acesso no seu e-mail.",
+ sendLink: "Enviar link",
+ sending: "Enviando…",
+ backToPassword: "Voltar ao login com senha",
+ forgotTitle: "Recuperar senha",
+ forgotSub: "Enviaremos um link para você criar uma nova senha.",
+ sendReset: "Enviar e-mail de recuperação",
+ sentMagicTitle: "Link enviado!",
+ sentMagicSub: "Verifique seu e-mail em",
+ sentForgotTitle: "E-mail enviado!",
+ sentForgotSub: "Verifique as instruções de recuperação em",
+ backToLogin: "Voltar ao login",
+ demo: "Ver demonstração sem cadastro →",
+ invalidCreds: "E-mail ou senha incorretos.",
+ langToggle: "English",
+ },
+};
+
export function LoginScreen({ onDemo }) {
- const [mode, setMode] = useState("password"); // "password" | "magic" | "forgot"
- const [email, setEmail] = useState("");
+ const [lang, setLang] = useState("en");
+ const [mode, setMode] = useState("password");
+ const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
- const [sent, setSent] = useState(false);
+ const [sent, setSent] = useState(false);
const [loading, setLoading] = useState(false);
- const [error, setError] = useState(null);
+ const [error, setError] = useState(null);
+
+ const s = STRINGS[lang];
+ const switchMode = m => { setMode(m); setError(null); setSent(false); };
+ const toggleLang = () => { setLang(l => l === "en" ? "pt" : "en"); setError(null); };
const inputFocus = e => { e.target.style.borderColor="var(--acc)"; e.target.style.boxShadow="0 0 0 3px var(--acc-d)"; };
const inputBlur = e => { e.target.style.borderColor="var(--border)"; e.target.style.boxShadow="none"; };
- const switchMode = m => { setMode(m); setError(null); setSent(false); };
async function handlePassword(e) {
e.preventDefault();
setLoading(true); setError(null);
const { error: err } = await supabase.auth.signInWithPassword({ email: email.trim(), password });
setLoading(false);
- if (err) setError(err.message === "Invalid login credentials" ? "E-mail ou senha incorretos." : err.message);
+ if (err) setError(err.message === "Invalid login credentials" ? s.invalidCreds : err.message);
}
async function handleMagicLink(e) {
@@ -47,16 +110,6 @@ export function LoginScreen({ onDemo }) {
setSent(true);
}
- const Logo = () => (
-
-
-
-
-
Interview OS
-
Command Center
-
- );
-
const ErrorBox = () => error ? (
{error}
) : null;
@@ -67,45 +120,71 @@ export function LoginScreen({ onDemo }) {
{title}
- {subtitle} {email}
-