diff --git a/frontend/ARCHITECTURE.md b/frontend/ARCHITECTURE.md index 5bbecec0..f9ed1f8a 100644 --- a/frontend/ARCHITECTURE.md +++ b/frontend/ARCHITECTURE.md @@ -1,25 +1,29 @@ # Arquitetura do Frontend -O frontend é organizado por domínios de negócio, não por tipos técnicos de arquivo. +O frontend usa Feature-Based Architecture. Cada funcionalidade mantém seus componentes, contratos e integrações; camadas internas existem somente quando necessárias. -## Camadas +## Organização -- `src/app`: composição da aplicação, providers, rotas e páginas de nível global. -- `src/domains//domain`: tipos de negócio e regras puras, sem React ou HTTP. -- `src/domains//application`: hooks React e casos de uso que orquestram regras de domínio. -- `src/domains//infrastructure`: gateways de API e código específico de transporte. -- `src/domains//presentation`: páginas e componentes pertencentes ao domínio. -- `src/shared`: primitivas de UI, assets, hooks e utilitários técnicos reutilizáveis. +- `src/app/routes`: definição das rotas e página de rota não encontrada. +- `src/app/providers`: composição dos providers globais. +- `src/app/layouts`: layout autenticado e seus componentes de navegação. +- `src/app/dashboard`: composição, navegação, providers e tema do painel atual. +- `src/features`: applications, auth, dashboard, help, home, jobs, marketing, mentoring, notifications e profile. +- `src/shared/api`: cliente HTTP compartilhado e tratamento de erros de API. +- `src/shared/utils`, `hooks`, `ui`, `assets` e `feedback`: utilitários, hooks, componentes, recursos e contexto de feedback reutilizáveis. +- `src/styles`: estilos globais. -> Exceção: o domínio `new_dashboard` ainda não segue integralmente essas 4 camadas. Hoje ele usa uma organização própria (`components/`, `context/`, `hooks/`, `infrastructure/`, `types/`, `utils/`, `layout.tsx`, `page.tsx`), sem pastas `domain/`, `application/` ou `presentation/` separadas. `auth`, `jobs` e `marketing` seguem o padrão de 4 camadas descrito acima. +## Convenções das funcionalidades -## Domínios +- `api/`: integrações HTTP; clientes e contratos existentes são preservados. +- `hooks/`: hooks de carregamento, persistência e estado. +- `components/` e `pages/`: componentes e páginas específicos da funcionalidade. +- `domain/` e `types/`: regras puras, schemas e contratos, quando necessários. +- `application/`: contextos e providers com responsabilidade de aplicação; não são hooks. +- Arquivos pequenos de contratos, eventos ou dados podem permanecer na raiz da funcionalidade. Não criar diretórios vazios. -- `auth`: estado de sessão, acesso à API de credenciais/OAuth e telas de login, registro e callback. -- `jobs`: entidades de vagas, filtros, deduplicação, paginação, acesso à API de vagas/scraper e UI de vagas. -- `marketing`: seções da landing page pública. -- `new_dashboard`: nova estrutura de painel do frontend. Contribuidores que procuram a experiência atual de dashboard devem começar por aqui. +O catálogo anterior mantém seu contrato em `features/jobs/types/job.types.ts`, distinto do contrato do painel em `features/jobs/domain/job.ts`. O contexto e os dados demonstrativos legados permanecem em `features/jobs/legacy`. Candidaturas, notas e Kanban pertencem a `features/applications`. -## Política de imports +## Imports -O código deve importar a partir de `@/app`, `@/domains` ou `@/shared`. Pastas técnicas antigas como `src/components`, `src/pages`, `src/hooks`, `src/services`, `src/context`, `src/lib` e `src/types` foram removidas para manter uma estrutura única orientada por domínio. +Preferir imports canônicos com `@/app`, `@/features` e `@/shared`. Preservar imports relativos locais quando claros. Código específico permanece na funcionalidade; somente recursos reutilizáveis pertencem a shared. Evitar dependências circulares. Rotas, exports, providers, APIs e modelos distintos são preservados pela organização. diff --git a/frontend/README.md b/frontend/README.md index 846ae4f1..d21c27cd 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -50,13 +50,13 @@ Variáveis usadas: ## Organização -O frontend segue uma estrutura orientada por domínio: +O frontend segue uma arquitetura Feature-Based, com domínio separado quando necessário: - `src/app`: composição da aplicação, providers, rotas e layouts autenticados. -- `src/domains/auth`: login, registro, callback OAuth e estado de sessão. -- `src/domains/jobs`: busca, filtros, paginação e interface de vagas. -- `src/domains/marketing`: landing page pública. -- `src/domains/new_dashboard`: nova estrutura de painel do frontend. É aqui que contribuidores devem procurar a experiência nova de dashboard, com abas de home, vagas, mentoring, perfil e ajuda. +- `src/features/auth`: login, registro, callback OAuth e estado de sessão. +- `src/features/jobs`: busca, filtros, paginação e interface de vagas, preservando os contratos distintos dos catálogos. +- `src/features/marketing`: landing page pública. +- `src/app/dashboard`: composição, providers, tema e navegação do painel. As funcionalidades de home, vagas, candidaturas, mentoria, perfil, ajuda e notificações estão em `src/features/`. - `src/shared`: componentes de UI, assets, hooks e utilitários reutilizáveis. Veja também [ARCHITECTURE.md](ARCHITECTURE.md). diff --git a/frontend/docs/PAV-182-match-vagas-salvas.md b/frontend/docs/PAV-182-match-vagas-salvas.md new file mode 100644 index 00000000..0ce14995 --- /dev/null +++ b/frontend/docs/PAV-182-match-vagas-salvas.md @@ -0,0 +1,73 @@ +# PAV-182 — Viabilidade de recuperar Match das vagas salvas + +## Resultado da auditoria + +O contrato atual não permite recuperar uma vaga específica do catálogo por +identificador estável. O enriquecimento das vagas salvas não foi implementado. +O Kanban mantém `Não calculado` quando a API não fornece compatibilidade. + +## Endpoint e perfil + +- O frontend usa `searchDashboardJobs`, em + `src/features/jobs/api/dashboardJobsApi.ts`, para consultar `GET /jobs/search`. +- O endpoint também está disponível sob `/api/v1/jobs/search` conforme a montagem + das rotas em `backend/src/app.ts`. +- O cliente utiliza cookies da sessão (`withCredentials`). O controller envia + `req.session.userId` para `SearchJobsService`, que carrega o perfil atual por + `JobProfileMatchService` e utiliza o cálculo existente de compatibilidade. +- A busca devolve `matchScore`, `matchSource` e `matchedTechnologies` quando + disponíveis. Ausência de score não equivale a um percentual calculado de zero. + +## Contrato de consulta + +O parser `backend/src/modules/jobs/parsers/jobSearchQuery.parser.ts` aceita: + +- `keywords`, `family`, `familyMode`, `technology`, `company`; +- `model`/`type`, `level`, `seniority`, `contract`/`contractType`/`jobTypes`; +- `location`, `continent`, `country`, `state`, `city`; +- `matchSort`/`sort` e paginação `page`, `limit`. + +Não há filtro de ID original, ID externo, URL, `jobLink` ou lote de IDs/URLs. +Enviar esses parâmetros não cria uma consulta por identidade: o parser não os +inclui nos filtros. As rotas públicas de catálogo são `/jobs/search` e +`/jobs/filters/options`; não existe rota de detalhe ou lookup do catálogo. +O limite de resultados por página é 100, definido em `backend/src/lib/pagination.ts`. + +## Identificadores e correspondência + +- A vaga salva possui um UUID de `saved_jobs`, diferente do ID do catálogo. +- Ela preserva `jobLink`, título, empresa, localização, fonte e uma keyword, + mas não uma associação ao ID original do catálogo nem os dados de Match. +- O frontend gera um ID de apresentação `recommended::<índice>` para a + busca. Esse valor não é um identificador de catálogo aceito pelo endpoint. +- Quando há URL original válida, `jobLink` permite comparar exatamente com + `url` de um resultado recebido. Isso não oferece um mecanismo para localizar + esse resultado sem percorrer a busca, nem garante unicidade no catálogo. +- Se a URL original estiver ausente, o frontend gera um link `canddate.local` + com dados textuais e índice da página; esse fallback não identifica uma vaga + original de forma estável. +- Título igual, empresa parcial ou primeiro resultado textual não comprovam + identidade. Providers diferentes e vagas homônimas não podem ser associados + automaticamente. A busca por empresa usa substring; keywords usam índices + de busca, sem contrato de correspondência exata por URL. + +## Decisão de implementação e performance + +Nenhuma nova requisição foi adicionada. Não há busca por card, varredura do +catálogo, paginação ilimitada ou cache novo de pontuações. A página atual de +resultados não foi utilizada como mecanismo de recuperação das vagas salvas: +ela não garante disponibilidade após recarregar e pode excluir essas vagas +pelos filtros ativos. + +## Dependência para uma solução definitiva + +É necessário um contrato autenticado de consulta em lote por identidade estável, +ou enriquecimento de `/saved-jobs` no servidor com associação ao catálogo. +Esse contrato deve resolver correspondências ausentes ou ambíguas explicitamente, +reutilizar o mesmo serviço de Match e o perfil atual, preservar zero real e +retornar score ausente quando não houver cálculo. Deve também definir expiração +e invalidação por mudanças de perfil/catálogo. Não basta persistir um percentual +fixo nem reaproveitar pontuações antigas pelo título. + +Essa alteração depende de backend e ficou fora desta etapa. Não há contrato +novo, endpoint proposto implementado ou alteração do cálculo de compatibilidade. diff --git a/frontend/src/app/App.tsx b/frontend/src/app/App.tsx index 5e495095..99efb9f4 100644 --- a/frontend/src/app/App.tsx +++ b/frontend/src/app/App.tsx @@ -1,5 +1,5 @@ import { useState, useEffect } from "react"; -import { AppRoutes } from "@/app/AppRoutes"; +import { AppRoutes } from "@/app/routes/AppRoutes"; import Loading from "@/shared/ui/Loading"; function App() { diff --git a/frontend/src/domains/new_dashboard/NewDashboardPage.tsx b/frontend/src/app/dashboard/DashboardPage.tsx similarity index 77% rename from frontend/src/domains/new_dashboard/NewDashboardPage.tsx rename to frontend/src/app/dashboard/DashboardPage.tsx index 48c4b8c7..0a2fd54c 100644 --- a/frontend/src/domains/new_dashboard/NewDashboardPage.tsx +++ b/frontend/src/app/dashboard/DashboardPage.tsx @@ -1,46 +1,37 @@ -import { useAuth } from "@/domains/auth/application/AuthContext"; +import { useAuth } from "@/features/auth/application/AuthContext"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useLocation, useNavigate, useSearchParams } from "react-router-dom"; -import { DashboardTab } from "./components/dashboard/DashboardTab"; -import { HelpTab } from "./components/help/HelpTab"; -import { HomeTab } from "./components/home/HomeTab"; -import { AddJobModal } from "./components/jobs/AddJobModal"; -import { JobDetailModal } from "./components/jobs/JobDetailModal"; -import { JobTab } from "./components/jobs/JobTab"; -import { Header } from "./components/layout/Header"; -import { MobileTabBar } from "./components/layout/MobileTabBar"; -import { Sidebar } from "./components/layout/Sidebar"; -import { MentoringTab } from "./components/mentoring/MentoringTab"; -import { ProfileTab } from "./components/profile/ProfileTab"; -import { Toast } from "./components/shared/Toast"; -import { jobStatuses } from "./constants"; -import { useDashboardJobs } from "./hooks/useDashboardJobs"; -import { useUserDashboardData } from "./hooks/useUserDashboardData"; +import { DashboardTab } from "@/features/dashboard/components/DashboardTab"; +import { HelpTab } from "@/features/help/components/HelpTab"; +import { HomeTab } from "@/features/home/components/HomeTab"; +import { AddJobModal } from "@/features/applications/components/AddJobModal"; +import { JobDetailModal } from "@/features/jobs/components/JobDetailModal"; +import { JobTab } from "@/features/jobs/components/JobTab"; +import { Header } from "@/app/dashboard/components/Header"; +import { MobileTabBar } from "@/app/dashboard/components/MobileTabBar"; +import { Sidebar } from "@/app/dashboard/components/Sidebar"; +import { MentoringTab } from "@/features/mentoring/components/MentoringTab"; +import { ProfileTab } from "@/features/profile/components/ProfileTab"; +import { Toast } from "@/shared/ui/Toast"; +import { jobStatuses } from "@/features/jobs/constants"; +import { useDashboardJobs } from "@/features/jobs/hooks/useDashboardJobs"; +import { useUserDashboardData } from "@/features/profile/hooks/useUserDashboardData"; import { getJobFilterOptions, FALLBACK_JOB_FAMILIES, type JobFamilyOption, -} from "./infrastructure/dashboardJobsApi"; -import type { - CareerChecklist, - Job, - JobModelFilter, - JobStatus, - MatchSort, - NewJob, - SearchPreferences, - TechnologyExperience, - UserProfile, -} from "./types"; +} from "@/features/jobs/api/dashboardJobsApi"; +import type { CareerChecklist, SearchPreferences, UserProfile } from "@/features/profile/domain/user"; +import type { Job, JobModelFilter, JobStatus, MatchSort, NewJob } from "@/features/jobs/domain/job"; import { type ContinentFilter, type CountryFilter, -} from "./utils/locationFilters"; +} from "@/features/jobs/domain/locationFilters"; import { getModelFilterFromJobTypes, modelFilterToApiFilter, -} from "./utils/jobModelFilters"; -import { parseSearchKeywords } from "./utils/searchKeywords"; +} from "@/features/jobs/domain/jobModelFilters"; +import { parseSearchKeywords } from "@/features/jobs/domain/searchKeywords"; function getSection(pathname: string) { if (pathname.startsWith("/dashboard")) return "dashboard"; @@ -51,109 +42,6 @@ function getSection(pathname: string) { return "home"; } -function normalizeMatchText(value: string) { - return value - .normalize("NFD") - .replace(/[\u0300-\u036f]/g, "") - .toLowerCase() - .replace(/[^\p{L}\p{N}]+/gu, " ") - .replace(/\s+/g, " ") - .trim(); -} - -function matchAliases(technology: string) { - const normalized = normalizeMatchText(technology); - const aliases = new Set([normalized]); - - if (normalized.endsWith(" js")) { - aliases.add(normalized.replace(/\s+js$/, "js")); - } - if (normalized.endsWith("js") && normalized.length > 2) { - aliases.add(normalized.replace(/js$/, " js")); - } - - return [...aliases].filter(Boolean); -} - -function textMatchesAlias(text: string, alias: string) { - if (!alias) return false; - if (alias.includes(" ")) return text.includes(alias); - return ` ${text} `.includes(` ${alias} `); -} - -function jobMatchText(job: Job) { - const rawPayload = job.rawPayload ?? {}; - const rawValues = Object.values(rawPayload).flatMap((value) => - Array.isArray(value) ? value : [value], - ); - - return normalizeMatchText( - [ - job.jobTitle, - job.company, - job.location, - job.type, - job.level, - job.tags.join(" "), - ...rawValues.map((value) => (typeof value === "string" ? value : "")), - ].join(" "), - ); -} - -function scoreJobWithTechnologies( - job: Job, - technologies: TechnologyExperience[], -): Job { - if (job.rawPayload?.matchSource === "backend_profile") return job; - - const normalizedTechnologies = [ - ...new Set( - technologies - .filter((technology) => technology.name.trim()) - .map((technology) => ({ - name: technology.name.trim(), - years: Math.max(0, technology.years), - })), - ), - ]; - if (normalizedTechnologies.length === 0) return job; - - const text = jobMatchText(job); - const matchedTechnologies = normalizedTechnologies.filter((technology) => - matchAliases(technology.name).some((alias) => - textMatchesAlias(text, alias), - ), - ); - - const totalWeight = normalizedTechnologies.reduce( - (total, technology) => total + Math.max(1, technology.years), - 0, - ); - const matchedWeight = matchedTechnologies.reduce( - (total, technology) => total + Math.max(1, technology.years), - 0, - ); - const coverage = matchedWeight / totalWeight; - const score = - matchedTechnologies.length === 0 - ? 45 - : Math.min( - 99, - 55 + - Math.round(coverage * 35) + - Math.min(matchedTechnologies.length * 4, 9), - ); - - return { - ...job, - matchScore: score, - rawPayload: { - ...(job.rawPayload ?? {}), - matchedTechnologies: matchedTechnologies.map((item) => item.name), - }, - }; -} - export default function NewDashboardPage() { const { user, refreshUser } = useAuth(); const location = useLocation(); @@ -174,6 +62,20 @@ export default function NewDashboardPage() { const [matchSort, setMatchSort] = useState( () => (searchParams.get("matchSort") as MatchSort) || "default", ); + const [stateFilter, setStateFilter] = useState( + () => searchParams.get("state") || "", + ); + const [cityFilter, setCityFilter] = useState( + () => searchParams.get("city") || "", + ); + // Captura os filtros da URL uma vez; alterações posteriores usam a busca + // com debounce, sem disparar outra carga inicial sem a ordenação atual. + const [initialRecommendationFilters] = useState(() => ({ + state: stateFilter.trim(), + city: cityFilter.trim(), + family: (searchParams.get("family") ?? "").split(",").filter(Boolean).join(","), + matchSort, + })); const [familyOptions, setFamilyOptions] = useState( FALLBACK_JOB_FAMILIES, ); @@ -237,6 +139,10 @@ export default function NewDashboardPage() { } else { nextParams.delete("matchSort"); } + if (stateFilter.trim()) nextParams.set("state", stateFilter.trim()); + else nextParams.delete("state"); + if (cityFilter.trim()) nextParams.set("city", cityFilter.trim()); + else nextParams.delete("city"); if (nextParams.toString() !== searchParams.toString()) { setSearchParams(nextParams, { replace: true }); @@ -248,6 +154,8 @@ export default function NewDashboardPage() { countryFilter, matchSort, searchParams, + stateFilter, + cityFilter, setSearchParams, ]); @@ -290,15 +198,28 @@ export default function NewDashboardPage() { keywords: [], filters: { ...modelFilterToApiFilter(preferredModelFilter), - ...(selectedFamilies.length > 0 + ...(initialRecommendationFilters.state + ? { state: initialRecommendationFilters.state } + : {}), + ...(initialRecommendationFilters.city + ? { city: initialRecommendationFilters.city } + : {}), + ...(initialRecommendationFilters.matchSort !== "default" + ? { matchSort: initialRecommendationFilters.matchSort } + : {}), + ...(initialRecommendationFilters.family ? { - family: selectedFamilies.join(","), + family: initialRecommendationFilters.family, familyMode: "primary", } : {}), }, }, - [isLoadingUserData, preferredModelFilter, selectedFamilies], + [ + isLoadingUserData, + preferredModelFilter, + initialRecommendationFilters, + ], ); const { trackedJobs, @@ -315,24 +236,11 @@ export default function NewDashboardPage() { initialRecommendationSearch, }); - const matchedTrackedJobs = useMemo( - () => - trackedJobs.map((job) => - scoreJobWithTechnologies(job, userProfile.technologyExperiences), - ), - [trackedJobs, userProfile.technologyExperiences], - ); - const matchedRecommendedJobs = useMemo( - () => - // O backend ja calcula o match com o mesmo perfil; so recalculamos aqui - // quando a API nao devolveu score (usuario sem perfil carregado). - recommendedJobs.map((job) => - job.matchScore > 0 - ? job - : scoreJobWithTechnologies(job, userProfile.technologyExperiences), - ), - [recommendedJobs, userProfile.technologyExperiences], - ); + // Vagas salvas também usam apenas a compatibilidade recebida da API. + const matchedTrackedJobs = trackedJobs; + // A API ordena globalmente por matchScore. Preservamos a mesma métrica + // recebida, inclusive zero e ausência, sem calcular um score após paginar. + const matchedRecommendedJobs = recommendedJobs; // O filtro de modelo e aplicado pelo backend (indice do Valkey + pos-filtro). // Refiltrar aqui escondia vagas ja filtradas e quebrava a contagem da pagina. const displayedRecommendedJobs = matchedRecommendedJobs; @@ -416,6 +324,14 @@ export default function NewDashboardPage() { setHasUserChangedJobFilters(true); setMatchSort(value); }, []); + const handleStateFilterChange = useCallback((value: string) => { + setHasUserChangedJobFilters(true); + setStateFilter(value); + }, []); + const handleCityFilterChange = useCallback((value: string) => { + setHasUserChangedJobFilters(true); + setCityFilter(value); + }, []); const handleSelectedFamiliesChange = useCallback((value: string[]) => { setHasUserChangedJobFilters(true); @@ -506,6 +422,8 @@ export default function NewDashboardPage() { ? { country: countryFilter, location: countryFilter } : {}), ...(matchSort !== "default" ? { matchSort } : {}), + ...(stateFilter.trim() ? { state: stateFilter.trim() } : {}), + ...(cityFilter.trim() ? { city: cityFilter.trim() } : {}), ...(selectedFamilies.length > 0 ? { family: selectedFamilies.join(","), @@ -532,6 +450,8 @@ export default function NewDashboardPage() { countryFilter, continentFilter, filterLevel, + stateFilter, + cityFilter, filterType, matchSort, selectedFamilies, @@ -572,6 +492,8 @@ export default function NewDashboardPage() { matchSort, selectedFamilies, section, + stateFilter, + cityFilter, ]); const handleRecommendationPageSizeChange = async (limit: number) => { @@ -609,6 +531,10 @@ export default function NewDashboardPage() { setContinentFilter={handleContinentFilterChange} countryFilter={countryFilter} setCountryFilter={handleCountryFilterChange} + stateFilter={stateFilter} + setStateFilter={handleStateFilterChange} + cityFilter={cityFilter} + setCityFilter={handleCityFilterChange} matchSort={matchSort} setMatchSort={handleMatchSortChange} selectedFamilies={selectedFamilies} diff --git a/frontend/src/domains/new_dashboard/layout.tsx b/frontend/src/app/dashboard/DashboardProviders.tsx similarity index 59% rename from frontend/src/domains/new_dashboard/layout.tsx rename to frontend/src/app/dashboard/DashboardProviders.tsx index 96e97f98..d4dc080b 100644 --- a/frontend/src/domains/new_dashboard/layout.tsx +++ b/frontend/src/app/dashboard/DashboardProviders.tsx @@ -1,7 +1,7 @@ import type { ReactNode } from "react"; -import { JobsProvider } from "./context/JobsContext"; -import { ThemeProvider } from "./context/ThemeContext"; -import { ToastProvider } from "./context/ToastContext"; +import { JobsProvider } from "@/features/jobs/legacy/application/JobsContext"; +import { ThemeProvider } from "@/app/dashboard/theme/ThemeContext"; +import { ToastProvider } from "@/shared/feedback/ToastContext"; export function NewDashboardLayout({ children }: { children: ReactNode }) { return ( diff --git a/frontend/src/domains/new_dashboard/components/shared/CandidateLogo.tsx b/frontend/src/app/dashboard/components/CandidateLogo.tsx similarity index 100% rename from frontend/src/domains/new_dashboard/components/shared/CandidateLogo.tsx rename to frontend/src/app/dashboard/components/CandidateLogo.tsx diff --git a/frontend/src/domains/new_dashboard/components/layout/Header.tsx b/frontend/src/app/dashboard/components/Header.tsx similarity index 97% rename from frontend/src/domains/new_dashboard/components/layout/Header.tsx rename to frontend/src/app/dashboard/components/Header.tsx index 0ecb2cc5..ae15f0b7 100644 --- a/frontend/src/domains/new_dashboard/components/layout/Header.tsx +++ b/frontend/src/app/dashboard/components/Header.tsx @@ -1,4 +1,4 @@ -import { useAuth } from "@/domains/auth/application/AuthContext"; +import { useAuth } from "@/features/auth/application/AuthContext"; import { Bell, ChevronDown, Mail } from "lucide-react"; import { useCallback, useEffect, useRef, useState } from "react"; import { useLocation, useNavigate } from "react-router-dom"; @@ -7,14 +7,15 @@ import { getDashboardNotificationFeed, markDashboardNotificationsRead, markSingleNotificationRead, -} from "../../infrastructure/notificationsApi"; -import type { Message, Notification, UserProfile } from "../../types"; +} from "@/features/notifications/api"; +import type { Message, Notification } from "@/features/notifications/types"; +import type { UserProfile } from "@/features/profile/domain/user"; import { DASHBOARD_NOTIFICATIONS_REFRESH_EVENT, type DashboardNotificationsRefreshDetail, -} from "../../utils/notificationEvents"; -import { MessageDetailModal } from "./MessageDetailModal"; -import { ThemeToggle } from "./ThemeToggle"; +} from "@/features/notifications/events"; +import { MessageDetailModal } from "@/features/notifications/components/MessageDetailModal"; +import { ThemeToggle } from "@/app/dashboard/components/ThemeToggle"; interface HeaderProps { title?: string; diff --git a/frontend/src/domains/new_dashboard/components/shared/Icons.tsx b/frontend/src/app/dashboard/components/Icons.tsx similarity index 100% rename from frontend/src/domains/new_dashboard/components/shared/Icons.tsx rename to frontend/src/app/dashboard/components/Icons.tsx diff --git a/frontend/src/domains/new_dashboard/components/layout/MobileTabBar.tsx b/frontend/src/app/dashboard/components/MobileTabBar.tsx similarity index 94% rename from frontend/src/domains/new_dashboard/components/layout/MobileTabBar.tsx rename to frontend/src/app/dashboard/components/MobileTabBar.tsx index b4fbd262..faad388a 100644 --- a/frontend/src/domains/new_dashboard/components/layout/MobileTabBar.tsx +++ b/frontend/src/app/dashboard/components/MobileTabBar.tsx @@ -1,6 +1,6 @@ -import { cn } from "@/shared/lib/utils"; +import { cn } from "@/shared/utils/utils"; import { Link, useLocation } from "react-router-dom"; -import { Icons } from "../shared/Icons"; +import { Icons } from "@/app/dashboard/components/Icons"; const tabs = [ { label: "Início", to: "/home", icon: Icons.Home }, diff --git a/frontend/src/domains/new_dashboard/components/layout/Sidebar.tsx b/frontend/src/app/dashboard/components/Sidebar.tsx similarity index 82% rename from frontend/src/domains/new_dashboard/components/layout/Sidebar.tsx rename to frontend/src/app/dashboard/components/Sidebar.tsx index fab6bb8d..6bf959a4 100644 --- a/frontend/src/domains/new_dashboard/components/layout/Sidebar.tsx +++ b/frontend/src/app/dashboard/components/Sidebar.tsx @@ -1,9 +1,9 @@ -import { cn } from "@/shared/lib/utils"; +import { cn } from "@/shared/utils/utils"; import { LogOut } from "lucide-react"; import { Link, useLocation } from "react-router-dom"; -import { useAuth } from "@/domains/auth/application/AuthContext"; -import { CandidateLogo } from "../shared/CandidateLogo"; -import { Icons } from "../shared/Icons"; +import { useAuth } from "@/features/auth/application/AuthContext"; +import { CandidateLogo } from "@/app/dashboard/components/CandidateLogo"; +import { Icons } from "@/app/dashboard/components/Icons"; const items = [ { label: "Início", to: "/home", icon: Icons.Home }, @@ -39,7 +39,7 @@ export function Sidebar() { className={cn( "flex h-10 items-center gap-3 rounded-md px-3 text-sm font-semibold transition-colors", active - ? "bg-primary text-primary-foreground shadow-sm" + ? "bg-[var(--menu-active-background)] text-[color:var(--menu-active-foreground)] shadow-sm ring-1 ring-inset ring-[color:var(--menu-active-border)]" : "text-slate-500 hover:bg-muted hover:text-foreground dark:text-slate-300", )} > @@ -62,7 +62,7 @@ export function Sidebar() { className={cn( "flex h-10 items-center gap-3 rounded-md px-3 text-sm font-semibold transition-colors", active - ? "bg-primary text-primary-foreground shadow-sm" + ? "bg-[var(--menu-active-background)] text-[color:var(--menu-active-foreground)] shadow-sm ring-1 ring-inset ring-[color:var(--menu-active-border)]" : "text-slate-500 hover:bg-muted hover:text-foreground dark:text-slate-300", )} > diff --git a/frontend/src/domains/new_dashboard/components/layout/ThemeToggle.tsx b/frontend/src/app/dashboard/components/ThemeToggle.tsx similarity index 100% rename from frontend/src/domains/new_dashboard/components/layout/ThemeToggle.tsx rename to frontend/src/app/dashboard/components/ThemeToggle.tsx diff --git a/frontend/src/domains/new_dashboard/context/ThemeContext.tsx b/frontend/src/app/dashboard/theme/ThemeContext.tsx similarity index 91% rename from frontend/src/domains/new_dashboard/context/ThemeContext.tsx rename to frontend/src/app/dashboard/theme/ThemeContext.tsx index a7d6bd66..8ea42d18 100644 --- a/frontend/src/domains/new_dashboard/context/ThemeContext.tsx +++ b/frontend/src/app/dashboard/theme/ThemeContext.tsx @@ -1,6 +1,6 @@ import { createContext, useContext, type ReactNode } from "react"; import { useTheme as useSharedTheme } from "@/shared/hooks/useTheme"; -import { darkColors, lightColors } from "../constants"; +import { darkColors, lightColors } from "@/app/dashboard/theme/colors"; type ThemeContextValue = ReturnType & { colors: typeof lightColors; diff --git a/frontend/src/app/dashboard/theme/colors.ts b/frontend/src/app/dashboard/theme/colors.ts new file mode 100644 index 00000000..69ad64c2 --- /dev/null +++ b/frontend/src/app/dashboard/theme/colors.ts @@ -0,0 +1,44 @@ +export interface Colors { + background: string; + surface: string; + surfaceHover: string; + input: string; + inputBorder: string; + primary: string; + primaryHover: string; + textPrimary: string; + textSecondary: string; + textMuted: string; + border: string; + borderFocus: string; +} + +export const lightColors: Colors = { + background: "#f8fafc", + surface: "#ffffff", + surfaceHover: "#f1f5f9", + input: "#f1f5f9", + inputBorder: "#cbd5e1", + primary: "#15803d", + primaryHover: "#166534", + textPrimary: "#0f172a", + textSecondary: "#475569", + textMuted: "#64748b", + border: "#e2e8f0", + borderFocus: "#15803d", +}; + +export const darkColors: Colors = { + background: "#0b0f19", + surface: "#131b2e", + surfaceHover: "#1e293b", + input: "#070a12", + inputBorder: "#26354a", + primary: "#16a34a", + primaryHover: "#22c55e", + textPrimary: "#ffffff", + textSecondary: "#9ca3af", + textMuted: "#94a3b8", + border: "#1e293b", + borderFocus: "#22c55e", +}; diff --git a/frontend/src/domains/new_dashboard/constants/tokens.ts b/frontend/src/app/dashboard/theme/tokens.ts similarity index 100% rename from frontend/src/domains/new_dashboard/constants/tokens.ts rename to frontend/src/app/dashboard/theme/tokens.ts diff --git a/frontend/src/app/dashboard/theme/useTheme.ts b/frontend/src/app/dashboard/theme/useTheme.ts new file mode 100644 index 00000000..03a69cae --- /dev/null +++ b/frontend/src/app/dashboard/theme/useTheme.ts @@ -0,0 +1 @@ +export { useDashboardTheme as useTheme } from "@/app/dashboard/theme/ThemeContext"; diff --git a/frontend/src/app/AuthenticatedHeader.tsx b/frontend/src/app/layouts/AuthenticatedHeader.tsx similarity index 97% rename from frontend/src/app/AuthenticatedHeader.tsx rename to frontend/src/app/layouts/AuthenticatedHeader.tsx index 59618b1e..419e6152 100644 --- a/frontend/src/app/AuthenticatedHeader.tsx +++ b/frontend/src/app/layouts/AuthenticatedHeader.tsx @@ -1,4 +1,4 @@ -import { useAuth } from "@/domains/auth/application/AuthContext"; +import { useAuth } from "@/features/auth/application/AuthContext"; import { useTheme } from "@/shared/hooks/useTheme"; import { ThemeToggle } from "@/shared/ui/theme-toggle"; import { Bell, Mail } from "lucide-react"; diff --git a/frontend/src/app/AuthenticatedLayout.tsx b/frontend/src/app/layouts/AuthenticatedLayout.tsx similarity index 75% rename from frontend/src/app/AuthenticatedLayout.tsx rename to frontend/src/app/layouts/AuthenticatedLayout.tsx index 98ed115a..49f87e8b 100644 --- a/frontend/src/app/AuthenticatedLayout.tsx +++ b/frontend/src/app/layouts/AuthenticatedLayout.tsx @@ -1,5 +1,5 @@ -import { AuthenticatedHeader } from "@/app/AuthenticatedHeader"; -import { AuthenticatedSidebar } from "@/app/AuthenticatedSidebar"; +import { AuthenticatedHeader } from "@/app/layouts/AuthenticatedHeader"; +import { AuthenticatedSidebar } from "@/app/layouts/AuthenticatedSidebar"; import { Outlet } from "react-router-dom"; export function AuthenticatedLayout() { diff --git a/frontend/src/app/AuthenticatedSidebar.tsx b/frontend/src/app/layouts/AuthenticatedSidebar.tsx similarity index 97% rename from frontend/src/app/AuthenticatedSidebar.tsx rename to frontend/src/app/layouts/AuthenticatedSidebar.tsx index 1a8d8224..09ab9cde 100644 --- a/frontend/src/app/AuthenticatedSidebar.tsx +++ b/frontend/src/app/layouts/AuthenticatedSidebar.tsx @@ -1,5 +1,5 @@ -import { useAuth } from "@/domains/auth/application/AuthContext"; -import { cn } from "@/shared/lib/utils"; +import { useAuth } from "@/features/auth/application/AuthContext"; +import { cn } from "@/shared/utils/utils"; import { BriefcaseBusiness, ChartNoAxesColumn, diff --git a/frontend/src/app/AppProviders.tsx b/frontend/src/app/providers/AppProviders.tsx similarity index 78% rename from frontend/src/app/AppProviders.tsx rename to frontend/src/app/providers/AppProviders.tsx index 263295e2..1a8cc4f5 100644 --- a/frontend/src/app/AppProviders.tsx +++ b/frontend/src/app/providers/AppProviders.tsx @@ -1,4 +1,4 @@ -import { AuthProvider } from "@/domains/auth/application/AuthContext"; +import { AuthProvider } from "@/features/auth/application/AuthContext"; import type { ReactNode } from "react"; import { BrowserRouter } from "react-router-dom"; diff --git a/frontend/src/app/AppRoutes.tsx b/frontend/src/app/routes/AppRoutes.tsx similarity index 73% rename from frontend/src/app/AppRoutes.tsx rename to frontend/src/app/routes/AppRoutes.tsx index 23d0db0e..bd6fa3c0 100644 --- a/frontend/src/app/AppRoutes.tsx +++ b/frontend/src/app/routes/AppRoutes.tsx @@ -1,13 +1,13 @@ -import NotFound from "@/app/NotFound"; -import { useAuth } from "@/domains/auth/application/AuthContext"; -import AuthCallbackPage from "@/domains/auth/presentation/pages/AuthCallbackPage"; -import LoginPage from "@/domains/auth/presentation/pages/LoginPage"; -import RegisterPage from "@/domains/auth/presentation/pages/RegisterPage"; -import LandingPage from "@/domains/marketing/presentation/pages/LandingPage"; -import PrivacyPolicyPage from "@/domains/marketing/presentation/pages/PrivacyPolicyPage"; -import TermsOfUsePage from "@/domains/marketing/presentation/pages/TermsOfUsePage"; -import NewDashboardPage from "@/domains/new_dashboard/NewDashboardPage"; -import NewDashboardLayout from "@/domains/new_dashboard/layout"; +import NotFound from "@/app/routes/NotFound"; +import { useAuth } from "@/features/auth/application/AuthContext"; +import AuthCallbackPage from "@/features/auth/pages/AuthCallbackPage"; +import LoginPage from "@/features/auth/pages/LoginPage"; +import RegisterPage from "@/features/auth/pages/RegisterPage"; +import LandingPage from "@/features/marketing/pages/LandingPage"; +import PrivacyPolicyPage from "@/features/marketing/pages/PrivacyPolicyPage"; +import TermsOfUsePage from "@/features/marketing/pages/TermsOfUsePage"; +import NewDashboardPage from "@/app/dashboard/DashboardPage"; +import NewDashboardLayout from "@/app/dashboard/DashboardProviders"; import Loading from "@/shared/ui/Loading"; import { Navigate, Route, Routes } from "react-router-dom"; diff --git a/frontend/src/app/NotFound.tsx b/frontend/src/app/routes/NotFound.tsx similarity index 100% rename from frontend/src/app/NotFound.tsx rename to frontend/src/app/routes/NotFound.tsx diff --git a/frontend/src/domains/marketing/presentation/pages/LandingPage.tsx b/frontend/src/domains/marketing/presentation/pages/LandingPage.tsx deleted file mode 100644 index eb987f60..00000000 --- a/frontend/src/domains/marketing/presentation/pages/LandingPage.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import { CTASection } from "@/domains/marketing/presentation/components/CTASection"; -import { FeaturesSection } from "@/domains/marketing/presentation/components/FeaturesSection"; -import { Footer } from "@/domains/marketing/presentation/components/Footer"; -import { HeroSection } from "@/domains/marketing/presentation/components/HeroSection"; -import { HowItWorks } from "@/domains/marketing/presentation/components/HowItWorks"; -import { Navbar } from "@/domains/marketing/presentation/components/Navbar"; -import TeamSection from "@/domains/marketing/presentation/components/TeamSection"; - -export default function LandingPage() { - return ( -
- -
- - - - - -
-
-
- ); -} diff --git a/frontend/src/domains/new_dashboard/components/jobs/JobFilter.tsx b/frontend/src/domains/new_dashboard/components/jobs/JobFilter.tsx deleted file mode 100644 index a8039576..00000000 --- a/frontend/src/domains/new_dashboard/components/jobs/JobFilter.tsx +++ /dev/null @@ -1,223 +0,0 @@ -import { Search, ChevronDown } from "lucide-react"; -import { useState, useRef, useEffect, useMemo } from "react"; -import { jobLevels } from "../../constants"; -import type { JobModelFilter, MatchSort } from "../../types"; -import { jobModelFilterOptions } from "../../utils/jobModelFilters"; -import { - continentOptions, - countryOptions, - type ContinentFilter, - type CountryFilter, -} from "../../utils/locationFilters"; - -interface JobFilterProps { - searchQuery: string; - setSearchQuery: (value: string) => void; - filterType: JobModelFilter; - setFilterType: (value: JobModelFilter) => void; - filterLevel: string; - setFilterLevel: (value: string) => void; - continentFilter: ContinentFilter; - setContinentFilter: (value: ContinentFilter) => void; - countryFilter: CountryFilter; - setCountryFilter: (value: CountryFilter) => void; - matchSort: MatchSort; - setMatchSort: (value: MatchSort) => void; - selectedFamilies?: string[]; - setSelectedFamilies?: (value: string[]) => void; - familyOptions?: Array<{ id: string; label: string }>; -} - -export function JobFilter({ - searchQuery, - setSearchQuery, - filterType, - setFilterType, - filterLevel, - setFilterLevel, - continentFilter, - setContinentFilter, - countryFilter, - setCountryFilter, - matchSort, - setMatchSort, - selectedFamilies = [], - setSelectedFamilies, - familyOptions = [], -}: JobFilterProps) { - const [isFamilyOpen, setIsFamilyOpen] = useState(false); - const familyDropdownRef = useRef(null); - - useEffect(() => { - const handleClickOutside = (event: MouseEvent) => { - if ( - familyDropdownRef.current && - !familyDropdownRef.current.contains(event.target as Node) - ) { - setIsFamilyOpen(false); - } - }; - document.addEventListener("mousedown", handleClickOutside); - return () => document.removeEventListener("mousedown", handleClickOutside); - }, []); - - const familyLabel = useMemo(() => { - const list = selectedFamilies ?? []; - const opts = familyOptions ?? []; - if (list.length === 0) return "Área profissional (Todas)"; - if (list.length === 1) { - const found = opts.find((f) => f.id === list[0]); - return found ? found.label : list[0]; - } - return `${list.length} áreas selecionadas`; - }, [selectedFamilies, familyOptions]); - - const activeSelected = selectedFamilies ?? []; - const activeOptions = familyOptions ?? []; - - return ( -
- - -
- - - {isFamilyOpen && ( -
-
- - Áreas profissionais - - {activeSelected.length > 0 && ( - - )} -
-
- {activeOptions.map((family) => { - const isSelected = activeSelected.includes(family.id); - return ( - - ); - })} -
-
- )} -
- - - - - - - - - - -
- ); -} diff --git a/frontend/src/domains/new_dashboard/components/jobs/JobRow.tsx b/frontend/src/domains/new_dashboard/components/jobs/JobRow.tsx deleted file mode 100644 index 43dc9329..00000000 --- a/frontend/src/domains/new_dashboard/components/jobs/JobRow.tsx +++ /dev/null @@ -1,109 +0,0 @@ -import { jobStatuses } from "../../constants"; -import type { Job, JobStatus } from "../../types"; -import { getFamilyLabel } from "../../infrastructure/dashboardJobsApi"; - -interface JobRowProps { - job: Job; - onOpen: (job: Job) => void; - onStatusChange: (jobId: string, status: JobStatus) => void; -} - -function normalizeSource(source: string) { - const normalized = source.trim().toLowerCase(); - const knownSources = [ - ["linkedin", "LinkedIn"], - ["adzuna", "Adzuna"], - ["jooble", "Jooble"], - ["greenhouse", "Greenhouse"], - ["gupy", "Gupy"], - ["indeed", "Indeed"], - ["glassdoor", "Glassdoor"], - ] as const; - - const match = knownSources.find(([key]) => normalized.includes(key)); - return match?.[1] ?? (source.trim() || "Não informada"); -} - -function matchedTechnologies(job: Job) { - const values = job.rawPayload?.matchedTechnologies; - return Array.isArray(values) - ? values.filter((value): value is string => typeof value === "string") - : []; -} - -function primaryFamily(job: Job) { - const classification = job.rawPayload?.classification as { primaryFamily?: string } | undefined; - if (!classification?.primaryFamily) return null; - return getFamilyLabel(classification.primaryFamily); -} - -export function JobRow({ job, onOpen, onStatusChange }: JobRowProps) { - const matched = matchedTechnologies(job); - const fam = primaryFamily(job); - - return ( - - - - - - - {normalizeSource(job.source)} - - - - {job.type} - - - {job.level} - - - {job.posted} - - - 0 - ? `Tecnologias em comum: ${matched.join(", ")}` - : "Sem tecnologias do perfil identificadas no texto da vaga" - } - > - {job.matchScore}% - - - - - - - - ); -} diff --git a/frontend/src/domains/new_dashboard/constants.ts b/frontend/src/domains/new_dashboard/constants.ts deleted file mode 100644 index 83379989..00000000 --- a/frontend/src/domains/new_dashboard/constants.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./constants/index"; diff --git a/frontend/src/domains/new_dashboard/constants/colors.ts b/frontend/src/domains/new_dashboard/constants/colors.ts deleted file mode 100644 index 62cc4014..00000000 --- a/frontend/src/domains/new_dashboard/constants/colors.ts +++ /dev/null @@ -1,36 +0,0 @@ -export interface Colors { - background: string; - surface: string; - surfaceHover: string; - input: string; - textPrimary: string; - textSecondary: string; - textMuted: string; - border: string; - borderFocus: string; -} - -export const lightColors: Colors = { - background: "#f8fafc", - surface: "#ffffff", - surfaceHover: "#f1f5f9", - input: "#f1f5f9", - textPrimary: "#0f172a", - textSecondary: "#475569", - textMuted: "#94a3b8", - border: "#edf2f7", - borderFocus: "#6366f1", -}; - -export const darkColors: Colors = { - background: "#0f172a", - surface: "#1e293b", - surfaceHover: "#334155", - input: "#1e293b", - textPrimary: "#f8fafc", - textSecondary: "#cbd5e1", - textMuted: "#94a3b8", - border: "#202d40", - borderFocus: "#818cf8", -}; - diff --git a/frontend/src/domains/new_dashboard/constants/index.ts b/frontend/src/domains/new_dashboard/constants/index.ts deleted file mode 100644 index 8c7479eb..00000000 --- a/frontend/src/domains/new_dashboard/constants/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -export * from "./colors"; -export * from "./tokens"; -export * from "./initialData"; - diff --git a/frontend/src/domains/new_dashboard/constants/initialData.ts b/frontend/src/domains/new_dashboard/constants/initialData.ts deleted file mode 100644 index 38789860..00000000 --- a/frontend/src/domains/new_dashboard/constants/initialData.ts +++ /dev/null @@ -1,176 +0,0 @@ -import type { - Job, - JobLevel, - JobStatus, - JobType, - Mentor, - SearchPreferences, - UserProfile, -} from "../types"; - -export const jobStatuses: Record = { - saved: "Salva", - applied: "Candidatura enviada", - interviewing: "Em entrevista", - rejected: "Arquivada", - accepted: "Proposta aceita", -}; - -export const jobStatusClasses: Record = { - saved: "border-amber-300 bg-amber-50 text-amber-700 dark:border-amber-500/40 dark:bg-amber-500/10 dark:text-amber-300", - applied: "border-sky-300 bg-sky-50 text-sky-700 dark:border-sky-500/40 dark:bg-sky-500/10 dark:text-sky-300", - interviewing: "border-violet-300 bg-violet-50 text-violet-700 dark:border-violet-500/40 dark:bg-violet-500/10 dark:text-violet-300", - rejected: "border-rose-300 bg-rose-50 text-rose-700 dark:border-rose-500/40 dark:bg-rose-500/10 dark:text-rose-300", - accepted: "border-emerald-300 bg-emerald-50 text-emerald-700 dark:border-emerald-500/40 dark:bg-emerald-500/10 dark:text-emerald-300", -}; - -export const jobTypes: JobType[] = ["Remoto", "Híbrido", "Presencial"]; -export const jobLevels: JobLevel[] = [ - "Estágio/Trainee", - "Júnior", - "Pleno", - "Sênior", -]; - -export const initialJobs: Job[] = [ - { - id: "f81d4fae-7dec-11d0-a765-00a0c91e6bf6", - jobTitle: "Desenvolvedor Frontend Sênior React", - company: "Fintech Hub Brasil", - location: "São Paulo, SP (Híbrido)", - salary: "R$ 14.000 - R$ 17.500", - type: "Híbrido", - level: "Sênior", - matchScore: 94, - tags: ["React", "TypeScript", "Tailwind CSS", "Next.js"], - posted: "Há 2 dias", - status: "saved", - jobLink: "https://fintechhub.com/vagas/react-senior", - source: "Gupy", - notes: "Preparar portfólio de fintech antes de submeter.", - }, - { - id: "6ec0bd7f-11c0-43da-975e-2a8ad9ebae0b", - jobTitle: "Engenheiro de Software Fullstack Pleno", - company: "Stellar Tech", - location: "Remoto", - salary: "R$ 8.500 - R$ 11.000", - type: "Remoto", - level: "Pleno", - matchScore: 89, - tags: ["React", "Node.js", "PostgreSQL", "Docker"], - posted: "Há 5 dias", - status: "applied", - jobLink: "https://stellartech.solides.jobs/vaga/fullstack-pleno", - source: "Solides", - notes: "Inscrito na vaga. Aguardando retorno do RH no e-mail.", - }, - { - id: "a3a1b894-3a5f-4a0b-9ffb-3b8d4e9c71bc", - jobTitle: "Designer de Produto UI/UX Júnior", - company: "Agência Criativa Digital", - location: "Rio de Janeiro, RJ (Presencial)", - salary: "R$ 4.500 - R$ 5.800", - type: "Presencial", - level: "Júnior", - matchScore: 78, - tags: ["Figma", "Prototipação", "Design Systems", "User Research"], - posted: "Há 1 dia", - status: "saved", - jobLink: "https://agenciacriativa.com/carreiras/uiux", - source: "LinkedIn", - notes: "Necessário enviar portfólio via formulário próprio.", - }, - { - id: "d9b2c374-e861-46da-b39f-7bcda238510a", - jobTitle: "Product Owner & Product Manager", - company: "Unicórnio Delivery", - location: "Remoto", - salary: "R$ 12.000 - R$ 15.000", - type: "Remoto", - level: "Sênior", - matchScore: 82, - tags: ["Agile", "Scrum", "Product Metrics", "Jira"], - posted: "Há 1 semana", - status: "interviewing", - jobLink: "https://unicorndelivery.gupy.io/jobs/19902", - source: "Gupy", - notes: "Entrevista marcada para terça-feira com a Product Lead Carolina.", - }, -]; - -export const initialMentors: Mentor[] = [ - { - id: "mentor-1", - name: "Julio Silva", - rating: 3, - completed: 33, - days: "Seg, Qua, Sex", - hours: "10:00 - 13:00", - nextSessionDate: "Próxima mentoria: quarta-feira, 24 jul", - specialty: "Frontend React, Design de Componentes e Otimização de Performance", - avatarColor: "from-teal-500 to-emerald-600", - platform: "Google Meet", - platformUrl: "https://meet.google.com/react-mentor-demo", - agenda: - "Revisão de componentes React, organização de estados, renderização eficiente e próximos passos para um portfólio frontend mais forte.", - }, - { - id: "mentor-2", - name: "Silvia Maria", - rating: 4, - completed: 103, - days: "Seg, Ter, Qua, Sex", - hours: "10:00 - 13:00", - nextSessionDate: "Próxima mentoria: terça-feira, 23 jul", - specialty: "Arquitetura Fullstack, Bancos de Dados Relacionais e APIs seguras", - avatarColor: "from-blue-500 to-indigo-600", - platform: "Zoom", - platformUrl: "https://zoom.us/j/1234567890", - agenda: - "Mapeamento de arquitetura fullstack, desenho de APIs, decisões de banco de dados e checklist de segurança para aplicações em produção.", - }, - { - id: "mentor-3", - name: "Bia Costa", - rating: 1, - completed: 3, - days: "Seg, Qua, Sex", - hours: "10:00 - 13:00", - nextSessionDate: "Próxima mentoria: sexta-feira, 26 jul", - specialty: "Design de Produto, Figma Avançado, Prototipação e Testes com Usuários", - avatarColor: "from-pink-500 to-purple-600", - platform: "Discord", - platformUrl: "https://discord.gg/product-design-demo", - agenda: - "Análise de fluxo de produto, refinamento de protótipos no Figma, narrativa de case e preparação para entrevistas de UX/UI.", - }, -]; - -export const initialUser: UserProfile = { - firstName: "Bruna", - lastName: "Silva", - displayName: "Bruna Silva", - username: "brunasilva", - email: "bruna.silva@exemplo.com", - avatarUrl: "", - phone: "(11) 98765-4321", - level: "Pleno", - technologies: ["React", "TypeScript", "Tailwind CSS", "Next.js", "Node.js"], - technologyExperiences: [ - { name: "React", years: 5 }, - { name: "TypeScript", years: 4 }, - { name: "Tailwind CSS", years: 3 }, - { name: "Next.js", years: 3 }, - { name: "Node.js", years: 2 }, - ], -}; - -export const initialPreferences: SearchPreferences = { - keywords: ["React", "Frontend", "Fullstack"], - searchLocation: "São Paulo, SP", - remoteOnly: false, - jobTypes: [], - emailNotifications: true, - careerChecklist: [], -}; diff --git a/frontend/src/domains/new_dashboard/hooks/useJobs.ts b/frontend/src/domains/new_dashboard/hooks/useJobs.ts deleted file mode 100644 index d2c7497d..00000000 --- a/frontend/src/domains/new_dashboard/hooks/useJobs.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { useJobsContext as useJobs } from "../context/JobsContext"; - diff --git a/frontend/src/domains/new_dashboard/hooks/useTheme.ts b/frontend/src/domains/new_dashboard/hooks/useTheme.ts deleted file mode 100644 index 42185eaf..00000000 --- a/frontend/src/domains/new_dashboard/hooks/useTheme.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { useDashboardTheme as useTheme } from "../context/ThemeContext"; - diff --git a/frontend/src/domains/new_dashboard/hooks/useToast.ts b/frontend/src/domains/new_dashboard/hooks/useToast.ts deleted file mode 100644 index eac84d5a..00000000 --- a/frontend/src/domains/new_dashboard/hooks/useToast.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { useToastContext as useToast } from "../context/ToastContext"; - diff --git a/frontend/src/domains/new_dashboard/page.tsx b/frontend/src/domains/new_dashboard/page.tsx deleted file mode 100644 index eca1fe43..00000000 --- a/frontend/src/domains/new_dashboard/page.tsx +++ /dev/null @@ -1,2 +0,0 @@ -export { default } from "./NewDashboardPage"; - diff --git a/frontend/src/domains/new_dashboard/types.ts b/frontend/src/domains/new_dashboard/types.ts deleted file mode 100644 index 4ca1e578..00000000 --- a/frontend/src/domains/new_dashboard/types.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./types/index"; diff --git a/frontend/src/domains/new_dashboard/types/index.ts b/frontend/src/domains/new_dashboard/types/index.ts deleted file mode 100644 index 3d97258b..00000000 --- a/frontend/src/domains/new_dashboard/types/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export * from "./job"; -export * from "./mentor"; -export * from "./user"; diff --git a/frontend/src/domains/new_dashboard/components/jobs/AddJobModal.tsx b/frontend/src/features/applications/components/AddJobModal.tsx similarity index 97% rename from frontend/src/domains/new_dashboard/components/jobs/AddJobModal.tsx rename to frontend/src/features/applications/components/AddJobModal.tsx index c899b304..0d7c41bd 100644 --- a/frontend/src/domains/new_dashboard/components/jobs/AddJobModal.tsx +++ b/frontend/src/features/applications/components/AddJobModal.tsx @@ -1,7 +1,7 @@ import { useState } from "react"; -import { jobLevels, jobTypes } from "../../constants"; -import type { NewJob } from "../../types"; -import { Modal } from "../shared/Modal"; +import { jobLevels, jobTypes } from "@/features/jobs/constants"; +import type { NewJob } from "@/features/jobs/domain/job"; +import { Modal } from "@/shared/ui/Modal"; const initialForm: NewJob = { jobTitle: "", diff --git a/frontend/src/domains/new_dashboard/components/jobs/ApplicationNotesSection.tsx b/frontend/src/features/applications/components/ApplicationNotesSection.tsx similarity index 98% rename from frontend/src/domains/new_dashboard/components/jobs/ApplicationNotesSection.tsx rename to frontend/src/features/applications/components/ApplicationNotesSection.tsx index 61a63137..b69adca7 100644 --- a/frontend/src/domains/new_dashboard/components/jobs/ApplicationNotesSection.tsx +++ b/frontend/src/features/applications/components/ApplicationNotesSection.tsx @@ -5,7 +5,7 @@ import { deleteDashboardApplicationNote, getDashboardApplicationNotes, updateDashboardApplicationNote, -} from "../../infrastructure/dashboardJobsApi"; +} from "@/features/jobs/api/dashboardJobsApi"; export function ApplicationNotesSection({ savedJobId }: { savedJobId: string }) { const [notes, setNotes] = useState([]); diff --git a/frontend/src/domains/new_dashboard/components/dashboard/KanbanBoard.tsx b/frontend/src/features/applications/components/KanbanBoard.tsx similarity index 94% rename from frontend/src/domains/new_dashboard/components/dashboard/KanbanBoard.tsx rename to frontend/src/features/applications/components/KanbanBoard.tsx index e1e42efa..e05fc0bd 100644 --- a/frontend/src/domains/new_dashboard/components/dashboard/KanbanBoard.tsx +++ b/frontend/src/features/applications/components/KanbanBoard.tsx @@ -1,5 +1,5 @@ import { useState } from "react"; -import type { Job, JobStatus } from "../../types"; +import type { Job, JobStatus } from "@/features/jobs/domain/job"; import { KanbanColumn } from "./KanbanColumn"; interface KanbanBoardProps { diff --git a/frontend/src/domains/new_dashboard/components/dashboard/KanbanColumn.tsx b/frontend/src/features/applications/components/KanbanColumn.tsx similarity index 96% rename from frontend/src/domains/new_dashboard/components/dashboard/KanbanColumn.tsx rename to frontend/src/features/applications/components/KanbanColumn.tsx index 0c36b256..7d33a7e3 100644 --- a/frontend/src/domains/new_dashboard/components/dashboard/KanbanColumn.tsx +++ b/frontend/src/features/applications/components/KanbanColumn.tsx @@ -1,4 +1,4 @@ -import type { Job, JobStatus } from "../../types"; +import type { Job, JobStatus } from "@/features/jobs/domain/job"; interface KanbanColumnProps { status: JobStatus; @@ -111,7 +111,7 @@ export function KanbanColumn({ {job.posted} - {job.matchScore}% Match + {job.matchScore === null ? "Não calculado" : `${job.matchScore}% Match`} diff --git a/frontend/src/domains/auth/infrastructure/authApi.ts b/frontend/src/features/auth/api/authApi.ts similarity index 97% rename from frontend/src/domains/auth/infrastructure/authApi.ts rename to frontend/src/features/auth/api/authApi.ts index 1d463818..d8ce1a88 100644 --- a/frontend/src/domains/auth/infrastructure/authApi.ts +++ b/frontend/src/features/auth/api/authApi.ts @@ -1,8 +1,8 @@ import type { LoginCredentials, RegisterData, -} from "@/domains/auth/domain/auth.types"; -import { parseApiError } from "@/shared/lib/apiError"; +} from "@/features/auth/domain/auth.types"; +import { parseApiError } from "@/shared/api/apiError"; function getBaseUrl(): string { const base = import.meta.env.VITE_API_BASE_URL; diff --git a/frontend/src/domains/auth/infrastructure/connectionsApi.ts b/frontend/src/features/auth/api/connectionsApi.ts similarity index 100% rename from frontend/src/domains/auth/infrastructure/connectionsApi.ts rename to frontend/src/features/auth/api/connectionsApi.ts diff --git a/frontend/src/domains/auth/application/AuthContext.tsx b/frontend/src/features/auth/application/AuthContext.tsx similarity index 92% rename from frontend/src/domains/auth/application/AuthContext.tsx rename to frontend/src/features/auth/application/AuthContext.tsx index abefd7b5..0850b468 100644 --- a/frontend/src/domains/auth/application/AuthContext.tsx +++ b/frontend/src/features/auth/application/AuthContext.tsx @@ -5,8 +5,8 @@ import { useState, ReactNode, } from "react"; -import type { LoginCredentials, User } from "@/domains/auth/domain/auth.types"; -import { api } from "@/shared/lib/apiClient"; +import type { LoginCredentials, User } from "@/features/auth/domain/auth.types"; +import { api } from "@/shared/api/apiClient"; interface AuthContextData { user: User | null; diff --git a/frontend/src/domains/auth/presentation/components/AuthVisualPanel.tsx b/frontend/src/features/auth/components/AuthVisualPanel.tsx similarity index 100% rename from frontend/src/domains/auth/presentation/components/AuthVisualPanel.tsx rename to frontend/src/features/auth/components/AuthVisualPanel.tsx diff --git a/frontend/src/domains/auth/presentation/components/LoginFormPanel.tsx b/frontend/src/features/auth/components/LoginFormPanel.tsx similarity index 99% rename from frontend/src/domains/auth/presentation/components/LoginFormPanel.tsx rename to frontend/src/features/auth/components/LoginFormPanel.tsx index 0726b4ab..5b563495 100644 --- a/frontend/src/domains/auth/presentation/components/LoginFormPanel.tsx +++ b/frontend/src/features/auth/components/LoginFormPanel.tsx @@ -3,8 +3,8 @@ import { getGoogleAuthUrl, getGithubAuthUrl, getLinkedinAuthUrl, -} from "@/domains/auth/infrastructure/authApi"; -import { useAuth } from "@/domains/auth/application/AuthContext"; +} from "@/features/auth/api/authApi"; +import { useAuth } from "@/features/auth/application/AuthContext"; import { Image } from "@unpic/react"; import { motion } from "framer-motion"; import { ArrowLeft, Eye, EyeOff } from "lucide-react"; diff --git a/frontend/src/domains/auth/presentation/components/RegisterFormPanel.tsx b/frontend/src/features/auth/components/RegisterFormPanel.tsx similarity index 99% rename from frontend/src/domains/auth/presentation/components/RegisterFormPanel.tsx rename to frontend/src/features/auth/components/RegisterFormPanel.tsx index 3f2245ad..56f1bfc0 100644 --- a/frontend/src/domains/auth/presentation/components/RegisterFormPanel.tsx +++ b/frontend/src/features/auth/components/RegisterFormPanel.tsx @@ -10,7 +10,7 @@ import { getGoogleAuthUrl, getLinkedinAuthUrl, register, -} from "../../infrastructure/authApi"; +} from "@/features/auth/api/authApi"; const STATIC_STARS = Array.from({ length: 40 }).map((_, i) => { const random = (min: number, max: number) => diff --git a/frontend/src/domains/auth/domain/auth.types.ts b/frontend/src/features/auth/domain/auth.types.ts similarity index 100% rename from frontend/src/domains/auth/domain/auth.types.ts rename to frontend/src/features/auth/domain/auth.types.ts diff --git a/frontend/src/domains/auth/presentation/pages/AuthCallbackPage.tsx b/frontend/src/features/auth/pages/AuthCallbackPage.tsx similarity index 91% rename from frontend/src/domains/auth/presentation/pages/AuthCallbackPage.tsx rename to frontend/src/features/auth/pages/AuthCallbackPage.tsx index bbc82865..3bde6558 100644 --- a/frontend/src/domains/auth/presentation/pages/AuthCallbackPage.tsx +++ b/frontend/src/features/auth/pages/AuthCallbackPage.tsx @@ -1,6 +1,6 @@ import { useEffect, useRef } from "react"; import { useNavigate } from "react-router-dom"; -import { useAuth } from "@/domains/auth/application/AuthContext"; +import { useAuth } from "@/features/auth/application/AuthContext"; import Loading from "@/shared/ui/Loading"; export default function AuthCallback() { diff --git a/frontend/src/domains/auth/presentation/pages/LoginPage.tsx b/frontend/src/features/auth/pages/LoginPage.tsx similarity index 58% rename from frontend/src/domains/auth/presentation/pages/LoginPage.tsx rename to frontend/src/features/auth/pages/LoginPage.tsx index 3413264c..2aae843c 100644 --- a/frontend/src/domains/auth/presentation/pages/LoginPage.tsx +++ b/frontend/src/features/auth/pages/LoginPage.tsx @@ -1,5 +1,5 @@ -import LeftSide from "@/domains/auth/presentation/components/AuthVisualPanel"; -import RightSide from "@/domains/auth/presentation/components/LoginFormPanel"; +import LeftSide from "@/features/auth/components/AuthVisualPanel"; +import RightSide from "@/features/auth/components/LoginFormPanel"; export default function LoginPage() { return ( diff --git a/frontend/src/domains/auth/presentation/pages/RegisterPage.tsx b/frontend/src/features/auth/pages/RegisterPage.tsx similarity index 59% rename from frontend/src/domains/auth/presentation/pages/RegisterPage.tsx rename to frontend/src/features/auth/pages/RegisterPage.tsx index 2c646cf8..6d178c3b 100644 --- a/frontend/src/domains/auth/presentation/pages/RegisterPage.tsx +++ b/frontend/src/features/auth/pages/RegisterPage.tsx @@ -1,5 +1,5 @@ -import LeftSide from "@/domains/auth/presentation/components/AuthVisualPanel"; -import RegisterSide from "@/domains/auth/presentation/components/RegisterFormPanel"; +import LeftSide from "@/features/auth/components/AuthVisualPanel"; +import RegisterSide from "@/features/auth/components/RegisterFormPanel"; export default function RegisterPage() { diff --git a/frontend/src/domains/new_dashboard/components/dashboard/DashboardTab.tsx b/frontend/src/features/dashboard/components/DashboardTab.tsx similarity index 96% rename from frontend/src/domains/new_dashboard/components/dashboard/DashboardTab.tsx rename to frontend/src/features/dashboard/components/DashboardTab.tsx index 640303f1..e2d59d86 100644 --- a/frontend/src/domains/new_dashboard/components/dashboard/DashboardTab.tsx +++ b/frontend/src/features/dashboard/components/DashboardTab.tsx @@ -1,6 +1,7 @@ import { BriefcaseBusiness, Plus } from "lucide-react"; -import type { Job, JobStatus, TechnologyExperience } from "../../types"; -import { KanbanBoard } from "./KanbanBoard"; +import type { Job, JobStatus } from "@/features/jobs/domain/job"; +import type { TechnologyExperience } from "@/features/profile/domain/user"; +import { KanbanBoard } from "@/features/applications/components/KanbanBoard"; interface DashboardTabProps { jobs: Job[]; diff --git a/frontend/src/domains/new_dashboard/components/help/HelpTab.tsx b/frontend/src/features/help/components/HelpTab.tsx similarity index 100% rename from frontend/src/domains/new_dashboard/components/help/HelpTab.tsx rename to frontend/src/features/help/components/HelpTab.tsx diff --git a/frontend/src/domains/new_dashboard/components/home/CareerChecklist.tsx b/frontend/src/features/home/components/CareerChecklist.tsx similarity index 99% rename from frontend/src/domains/new_dashboard/components/home/CareerChecklist.tsx rename to frontend/src/features/home/components/CareerChecklist.tsx index a1155d13..b54c9a87 100644 --- a/frontend/src/domains/new_dashboard/components/home/CareerChecklist.tsx +++ b/frontend/src/features/home/components/CareerChecklist.tsx @@ -1,6 +1,6 @@ import { ListChecks, Plus, Trash2 } from "lucide-react"; import { useEffect, useMemo, useState } from "react"; -import type { CareerChecklist as CareerChecklistList } from "../../types"; +import type { CareerChecklist as CareerChecklistList } from "@/features/profile/domain/user"; type ChecklistItem = { id: string; diff --git a/frontend/src/domains/new_dashboard/components/home/HomeTab.tsx b/frontend/src/features/home/components/HomeTab.tsx similarity index 93% rename from frontend/src/domains/new_dashboard/components/home/HomeTab.tsx rename to frontend/src/features/home/components/HomeTab.tsx index f4dbeb87..8d222223 100644 --- a/frontend/src/domains/new_dashboard/components/home/HomeTab.tsx +++ b/frontend/src/features/home/components/HomeTab.tsx @@ -1,6 +1,7 @@ import { ClipboardList } from "lucide-react"; -import { jobStatuses } from "../../constants"; -import type { CareerChecklist as CareerChecklistList, Job, UserProfile } from "../../types"; +import { jobStatuses } from "@/features/jobs/constants"; +import type { CareerChecklist as CareerChecklistList, UserProfile } from "@/features/profile/domain/user"; +import type { Job } from "@/features/jobs/domain/job"; import { CareerChecklist } from "./CareerChecklist"; import { StatusCounters } from "./StatusCounters"; import { WelcomeBanner } from "./WelcomeBanner"; diff --git a/frontend/src/domains/new_dashboard/components/home/StatusConters.tsx b/frontend/src/features/home/components/StatusConters.tsx similarity index 100% rename from frontend/src/domains/new_dashboard/components/home/StatusConters.tsx rename to frontend/src/features/home/components/StatusConters.tsx diff --git a/frontend/src/domains/new_dashboard/components/home/StatusCounters.tsx b/frontend/src/features/home/components/StatusCounters.tsx similarity index 96% rename from frontend/src/domains/new_dashboard/components/home/StatusCounters.tsx rename to frontend/src/features/home/components/StatusCounters.tsx index 8e472fe4..e8b1d622 100644 --- a/frontend/src/domains/new_dashboard/components/home/StatusCounters.tsx +++ b/frontend/src/features/home/components/StatusCounters.tsx @@ -1,4 +1,4 @@ -import type { Job } from "../../types"; +import type { Job } from "@/features/jobs/domain/job"; interface StatusCountersProps { jobs: Job[]; diff --git a/frontend/src/domains/new_dashboard/components/home/WelcomeBanner.tsx b/frontend/src/features/home/components/WelcomeBanner.tsx similarity index 84% rename from frontend/src/domains/new_dashboard/components/home/WelcomeBanner.tsx rename to frontend/src/features/home/components/WelcomeBanner.tsx index 31c61001..1afdcd58 100644 --- a/frontend/src/domains/new_dashboard/components/home/WelcomeBanner.tsx +++ b/frontend/src/features/home/components/WelcomeBanner.tsx @@ -1,5 +1,5 @@ import { BriefcaseBusiness, Sparkles } from "lucide-react"; -import type { UserProfile } from "../../types"; +import type { UserProfile } from "@/features/profile/domain/user"; interface WelcomeBannerProps { userProfile: UserProfile; @@ -13,7 +13,7 @@ export function WelcomeBanner({ onExploreJobs, }: WelcomeBannerProps) { return ( -
+
@@ -26,7 +26,7 @@ export function WelcomeBanner({ Que bom te ver de volta, {userProfile.displayName}! -

+

Sua conta de nível{" "} {userProfile.level} monitora atualmente {jobsCount}{" "} diff --git a/frontend/src/domains/new_dashboard/infrastructure/dashboardJobsApi.ts b/frontend/src/features/jobs/api/dashboardJobsApi.ts similarity index 97% rename from frontend/src/domains/new_dashboard/infrastructure/dashboardJobsApi.ts rename to frontend/src/features/jobs/api/dashboardJobsApi.ts index d3ce88d7..12eb204f 100644 --- a/frontend/src/domains/new_dashboard/infrastructure/dashboardJobsApi.ts +++ b/frontend/src/features/jobs/api/dashboardJobsApi.ts @@ -1,4 +1,4 @@ -import { api } from "@/shared/lib/apiClient"; +import { api } from "@/shared/api/apiClient"; import { z } from "zod"; import type { Job, @@ -8,7 +8,7 @@ import type { JobType, MatchSort, NewJob, -} from "../types"; +} from "@/features/jobs/domain/job"; const ApiSearchJobSchema = z .object({ @@ -252,14 +252,6 @@ function inferLevel(title: string): JobLevel { return "Pleno"; } -function stableMatchScore(value: string) { - const hash = [...value].reduce( - (total, character) => total + character.charCodeAt(0), - 0, - ); - return 70 + (hash % 26); -} - function formatPublicationDate(value: string | null | undefined) { const rawDate = value?.trim(); if (!rawDate) return "Não informado"; @@ -329,7 +321,7 @@ export function toRecommendedJob(job: ApiSearchJob, index: number): Job { ), level: inferLevel(title), // Sem perfil o backend nao calcula match; nao inventamos um score aqui. - matchScore: typeof job.matchScore === "number" ? job.matchScore : 0, + matchScore: typeof job.matchScore === "number" ? job.matchScore : null, tags: tags.length > 0 ? tags : ["Geral"], posted: formatPublicationDate(job.postedAt), status: "saved", @@ -353,7 +345,8 @@ export function toDashboardSavedJob(job: ApiSavedJob): Job { salary: "A combinar", type: inferTypeFromText([title, location].join(" ")), level: inferLevel(title), - matchScore: stableMatchScore(`${title}:${job.company ?? ""}`), + // O contrato de vagas salvas ainda não retorna compatibilidade. + matchScore: null, tags: job.keyword?.trim() ? [job.keyword.trim()] : ["Geral"], posted: "Não informado", status: job.status, diff --git a/frontend/src/domains/jobs/infrastructure/jobsApi.ts b/frontend/src/features/jobs/api/jobsApi.ts similarity index 98% rename from frontend/src/domains/jobs/infrastructure/jobsApi.ts rename to frontend/src/features/jobs/api/jobsApi.ts index 28e0351f..47f7931d 100644 --- a/frontend/src/domains/jobs/infrastructure/jobsApi.ts +++ b/frontend/src/features/jobs/api/jobsApi.ts @@ -1,4 +1,4 @@ -import type { JobsResponse } from "@/domains/jobs/domain/job.types"; +import type { JobsResponse } from "@/features/jobs/types/job.types"; function normalizeBaseUrl(value: string | undefined): string { return typeof value === "string" ? value.trim().replace(/\/+$/, "") : ""; diff --git a/frontend/src/domains/new_dashboard/components/jobs/FormattedJobDescription.tsx b/frontend/src/features/jobs/components/FormattedJobDescription.tsx similarity index 90% rename from frontend/src/domains/new_dashboard/components/jobs/FormattedJobDescription.tsx rename to frontend/src/features/jobs/components/FormattedJobDescription.tsx index ba39af96..357c85fd 100644 --- a/frontend/src/domains/new_dashboard/components/jobs/FormattedJobDescription.tsx +++ b/frontend/src/features/jobs/components/FormattedJobDescription.tsx @@ -1,4 +1,5 @@ import { Fragment, type ReactNode } from "react"; +import { normalizeDescriptionText } from "../utils/jobDetailPresentation"; interface FormattedJobDescriptionProps { description: string; @@ -54,7 +55,7 @@ function safeExternalUrl(value: string | null) { function renderNode(node: Node, key: string): ReactNode { if (node.nodeType === Node.TEXT_NODE) { - return node.textContent; + return node.parentElement?.closest("pre, code") ? node.textContent : normalizeDescriptionText(node.textContent ?? ""); } if (node.nodeType !== Node.ELEMENT_NODE) { @@ -134,6 +135,9 @@ export function FormattedJobDescription({ "text/html", ); const hasMarkup = /<\/?[a-z][\s\S]*>/i.test(decodedDescription); + document.querySelectorAll("br + br + br").forEach((element) => { + if (!element.closest("pre, code")) element.remove(); + }); return (

@@ -143,7 +147,7 @@ export function FormattedJobDescription({ ) ) : (

- {document.body.textContent ?? decodedDescription} + {normalizeDescriptionText(document.body.textContent ?? decodedDescription).trim()}

)}
diff --git a/frontend/src/domains/new_dashboard/components/jobs/JobDetailModal.tsx b/frontend/src/features/jobs/components/JobDetailModal.tsx similarity index 88% rename from frontend/src/domains/new_dashboard/components/jobs/JobDetailModal.tsx rename to frontend/src/features/jobs/components/JobDetailModal.tsx index a0707757..39c1536c 100644 --- a/frontend/src/domains/new_dashboard/components/jobs/JobDetailModal.tsx +++ b/frontend/src/features/jobs/components/JobDetailModal.tsx @@ -1,14 +1,16 @@ import { ExternalLink } from "lucide-react"; -import { useEffect, useState } from "react"; -import { jobStatusClasses, jobStatuses } from "../../constants"; +import { useEffect, useState, type ReactNode } from "react"; +import { jobStatusClasses, jobStatuses } from "../constants"; import { getDashboardSavedJobEvents, getFamilyLabel, -} from "../../infrastructure/dashboardJobsApi"; -import type { Job, JobStatus, JobTimelineEvent } from "../../types"; -import { Modal } from "../shared/Modal"; -import { ApplicationNotesSection } from "./ApplicationNotesSection"; -import { FormattedJobDescription } from "./FormattedJobDescription"; +} from "@/features/jobs/api/dashboardJobsApi"; +import type { Job, JobStatus, JobTimelineEvent } from "../domain/job"; +import { Modal } from "@/shared/ui/Modal"; +import { ApplicationNotesSection } from "@/features/applications/components/ApplicationNotesSection"; +import { FormattedJobDescription } from "@/features/jobs/components/FormattedJobDescription"; +import { JobLocation } from "./JobLocation"; +import { jobDetailLabel, formatJobPublicationDate, formatCompatibilityReason } from "../utils/jobDetailPresentation"; interface JobDetailModalProps { job: Job; @@ -19,22 +21,6 @@ interface JobDetailModalProps { timelineVersion?: number; } -const payloadLabels: Record = { - id: "ID", - title: "Título", - company: "Empresa", - location: "Local", - url: "URL", - salary: "Salário", - modality: "Modalidade", - description: "Descrição", - postedAt: "Publicado em", - source: "Fonte", - sources: "Fontes", - keyword: "Keyword", - keywords: "Keywords", -}; - /** * Campos do payload bruto que já têm representação amigável em outro lugar * do detalhe (tiles principais, subtítulo do modal ou link "Abrir vaga") — @@ -91,7 +77,7 @@ function timelineMetadataText(metadata: Record | null) { if (!metadata) return ""; return Object.entries(metadata) - .map(([key, value]) => `${key}: ${payloadValueToText(value)}`) + .map(([key, value]) => `${jobDetailLabel(key)}: ${payloadValueToText(value)}`) .join(" • "); } @@ -102,13 +88,13 @@ function InfoTile({ valueClassName = "", }: { label: string; - value: string; + value: ReactNode; valueClassName?: string; }) { return (
{label} -

{value}

+
{value}
); } @@ -202,14 +188,14 @@ export function JobDetailModal({
- + } /> {classification.primaryFamily ? ( @@ -261,11 +247,11 @@ export function JobDetailModal({ {classification.reasons && classification.reasons.length > 0 ? (
- Justificativas de match + Justificativas da compatibilidade
    {classification.reasons.map((reason, idx) => ( -
  • {reason}
  • +
  • {formatCompatibilityReason(reason)}
  • ))}
@@ -287,7 +273,8 @@ export function JobDetailModal({
{payloadEntries.map(([key, value]) => { - const text = payloadValueToText(value); + const isPublication = key === "postedAt" || key === "publishedAt"; + const text = isPublication ? formatJobPublicationDate(value) : payloadValueToText(value); return (
- {payloadLabels[key] ?? key} + {jobDetailLabel(key)} {isExternalUrl(text) ? ( ) : ( -

+

{text}

)} diff --git a/frontend/src/features/jobs/components/JobFilter.tsx b/frontend/src/features/jobs/components/JobFilter.tsx new file mode 100644 index 00000000..97fd70e3 --- /dev/null +++ b/frontend/src/features/jobs/components/JobFilter.tsx @@ -0,0 +1,409 @@ +import { + Search, + ChevronDown, + Filter, + Flag, + Globe2, + MapPinned, + Building2, + X, +} from "lucide-react"; +import { + useState, + useRef, + useEffect, + useMemo, + useId, + type CSSProperties, +} from "react"; +import { cn } from "@/shared/utils/utils"; +import { darkColors, lightColors } from "@/app/dashboard/theme/colors"; +import { jobLevels } from "../constants"; +import type { JobModelFilter } from "../domain/job"; +import { jobModelFilterOptions } from "@/features/jobs/domain/jobModelFilters"; +import { + continentOptions, + countryOptions, + type ContinentFilter, + type CountryFilter, +} from "@/features/jobs/domain/locationFilters"; + +interface JobFilterProps { + searchQuery: string; + setSearchQuery: (value: string) => void; + filterType: JobModelFilter; + setFilterType: (value: JobModelFilter) => void; + filterLevel: string; + setFilterLevel: (value: string) => void; + continentFilter: ContinentFilter; + setContinentFilter: (value: ContinentFilter) => void; + countryFilter: CountryFilter; + setCountryFilter: (value: CountryFilter) => void; + stateFilter: string; + setStateFilter: (value: string) => void; + cityFilter: string; + setCityFilter: (value: string) => void; + selectedFamilies?: string[]; + setSelectedFamilies?: (value: string[]) => void; + familyOptions?: Array<{ id: string; label: string }>; +} + +export function JobFilter({ + searchQuery, + setSearchQuery, + filterType, + setFilterType, + filterLevel, + setFilterLevel, + continentFilter, + setContinentFilter, + countryFilter, + setCountryFilter, + stateFilter, + setStateFilter, + cityFilter, + setCityFilter, + selectedFamilies = [], + setSelectedFamilies, + familyOptions = [], +}: JobFilterProps) { + const [isFamilyOpen, setIsFamilyOpen] = useState(false); + const familyDropdownRef = useRef(null); + const familyDropdownId = useId(); + + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if ( + familyDropdownRef.current && + !familyDropdownRef.current.contains(event.target as Node) + ) { + setIsFamilyOpen(false); + } + }; + document.addEventListener("mousedown", handleClickOutside); + return () => document.removeEventListener("mousedown", handleClickOutside); + }, []); + + const familyLabel = useMemo(() => { + const list = selectedFamilies ?? []; + const opts = familyOptions ?? []; + if (list.length === 0) return "Áreas profissionais (Todas)"; + if (list.length === 1) { + const found = opts.find((f) => f.id === list[0]); + return found ? found.label : list[0]; + } + return `${list.length} áreas selecionadas`; + }, [selectedFamilies, familyOptions]); + + const activeSelected = selectedFamilies ?? []; + const activeOptions = familyOptions ?? []; + const inputColors = { + "--filter-input-light": lightColors.input, + "--filter-input-dark": darkColors.input, + } as CSSProperties; + const controlClassName = + "h-10 w-full min-w-0 rounded-md border border-input bg-[var(--filter-input-light)] px-3 text-sm text-foreground outline-none transition-colors placeholder:text-muted-foreground hover:border-primary/40 focus-visible:border-ring focus-visible:ring-2 focus-visible:ring-ring/25 dark:bg-[var(--filter-input-dark)]"; + const otherActiveFilters = [ + searchQuery.trim() && `Busca: ${searchQuery.trim()}`, + filterType !== "Todos" && + (jobModelFilterOptions.find((option) => option.value === filterType) + ?.label ?? + filterType), + filterLevel !== "Todos" && filterLevel, + continentFilter !== "Todos" && continentFilter, + countryFilter !== "Todos" && countryFilter, + stateFilter.trim() && `Estado / Região: ${stateFilter.trim()}`, + cityFilter.trim() && `Cidade: ${cityFilter.trim()}`, + ].filter(Boolean); + + return ( + <> +
+
+ + +
{ + if (event.key === "Escape") { + setIsFamilyOpen(false); + familyDropdownRef.current?.querySelector("button")?.focus(); + } + }} + > + + + {isFamilyOpen && ( +
+
+ + Áreas profissionais + + +
+
+ {activeOptions.map((family) => { + const isSelected = activeSelected.includes(family.id); + return ( + + ); + })} +
+
+ )} +
+ + + + +
+ +
+ + + + + + +
+ +
+ + + {activeSelected.length > 0 + ? activeSelected.map((id) => ( + + + {activeOptions.find((family) => family.id === id)?.label ?? + id} + + {setSelectedFamilies && ( + + )} + + )) + : null} + {otherActiveFilters.map((label) => ( + + {label} + + ))} + {activeSelected.length === 0 && otherActiveFilters.length === 0 && ( + + Nenhum filtro específico selecionado. + + )} +
+
+ + ); +} diff --git a/frontend/src/domains/new_dashboard/components/jobs/JobFilters.tsx b/frontend/src/features/jobs/components/JobFilters.tsx similarity index 100% rename from frontend/src/domains/new_dashboard/components/jobs/JobFilters.tsx rename to frontend/src/features/jobs/components/JobFilters.tsx diff --git a/frontend/src/features/jobs/components/JobLocation.tsx b/frontend/src/features/jobs/components/JobLocation.tsx new file mode 100644 index 00000000..c6303fff --- /dev/null +++ b/frontend/src/features/jobs/components/JobLocation.tsx @@ -0,0 +1,72 @@ +import { MapPin, X } from "lucide-react"; +import { Popover } from "radix-ui"; +import { formatJobLocation } from "../utils/formatJobLocation"; + +export function JobLocation({ + location, + structured, +}: { + location: string; + structured?: Record; +}) { + const formatted = formatJobLocation(location, structured); + return ( +
+
+ ); +} diff --git a/frontend/src/features/jobs/components/JobRow.tsx b/frontend/src/features/jobs/components/JobRow.tsx new file mode 100644 index 00000000..3cc5aea7 --- /dev/null +++ b/frontend/src/features/jobs/components/JobRow.tsx @@ -0,0 +1,200 @@ +import { jobStatuses } from "../constants"; +import type { Job, JobStatus } from "../domain/job"; +import { getFamilyLabel } from "@/features/jobs/api/dashboardJobsApi"; +import { JobLocation } from "./JobLocation"; +import { TechnologyTags } from "./TechnologyTags"; + +interface JobRowProps { + job: Job; + onOpen: (job: Job) => void; + onStatusChange: (jobId: string, status: JobStatus) => void; +} + +function normalizeSource(source: string) { + const normalized = source.trim().toLowerCase(); + const knownSources = [ + ["linkedin", "LinkedIn"], + ["adzuna", "Adzuna"], + ["jooble", "Jooble"], + ["greenhouse", "Greenhouse"], + ["gupy", "Gupy"], + ["indeed", "Indeed"], + ["glassdoor", "Glassdoor"], + ] as const; + + const match = knownSources.find(([key]) => normalized.includes(key)); + return match?.[1] ?? (source.trim() || "Não informada"); +} + +function matchedTechnologies(job: Job) { + const values = job.rawPayload?.matchedTechnologies; + return Array.isArray(values) + ? values.filter((value): value is string => typeof value === "string") + : []; +} + +function primaryFamily(job: Job) { + const classification = job.rawPayload?.classification as + { primaryFamily?: string } | undefined; + if (!classification?.primaryFamily) return null; + return getFamilyLabel(classification.primaryFamily); +} + +function stringValues(value: unknown): string[] { + return Array.isArray(value) + ? [ + ...new Set( + value + .filter((item): item is string => typeof item === "string") + .map((item) => item.trim()) + .filter(Boolean), + ), + ] + : []; +} + +function jobTechnologies(job: Job) { + const classification = job.rawPayload?.classification as + { technologies?: unknown } | undefined; + const classified = stringValues(classification?.technologies); + if (classified.length > 0) return classified; + const matched = stringValues(job.rawPayload?.matchedTechnologies); + return matched.length > 0 + ? matched + : stringValues(job.tags).filter((tag) => tag !== "Geral"); +} + +export function JobRow({ job, onOpen, onStatusChange }: JobRowProps) { + const matched = matchedTechnologies(job); + const fam = primaryFamily(job); + const technologies = jobTechnologies(job); + + return ( + + +
+
+ + {technologies.length > 0 && ( + + )} +
+ {fam ? ( + + {fam} + + ) : null} +
+ + + + Localização regional + + + + + + Fonte + + + {normalizeSource(job.source)} + + + + + Modelo + + {job.type} + + + + Nível + + {job.level} + + + + Publicada em + + {job.posted} + + + + Match + + 0 + ? `Tecnologias em comum: ${matched.join(", ")}` + : "Sem tecnologias do perfil identificadas no texto da vaga" + } + > + {job.matchScore === null ? "Não calculado" : `${job.matchScore}%`} + + + +
+ + +
+ + + ); +} diff --git a/frontend/src/domains/new_dashboard/components/jobs/JobTab.tsx b/frontend/src/features/jobs/components/JobTab.tsx similarity index 73% rename from frontend/src/domains/new_dashboard/components/jobs/JobTab.tsx rename to frontend/src/features/jobs/components/JobTab.tsx index 04cd2598..d170e2cf 100644 --- a/frontend/src/domains/new_dashboard/components/jobs/JobTab.tsx +++ b/frontend/src/features/jobs/components/JobTab.tsx @@ -4,16 +4,16 @@ import type { JobModelFilter, JobStatus, MatchSort, - SearchPreferences, -} from "../../types"; +} from "../domain/job"; +import type { SearchPreferences } from "@/features/profile/domain/user"; import { type ContinentFilter, type CountryFilter, -} from "../../utils/locationFilters"; +} from "@/features/jobs/domain/locationFilters"; import { getModelFilterFromJobTypes, jobModelFilterOptions, -} from "../../utils/jobModelFilters"; +} from "@/features/jobs/domain/jobModelFilters"; import { JobFilter } from "./JobFilter"; import { JobTable } from "./JobTable"; @@ -29,6 +29,10 @@ interface JobTabProps { setContinentFilter: (value: ContinentFilter) => void; countryFilter: CountryFilter; setCountryFilter: (value: CountryFilter) => void; + stateFilter: string; + setStateFilter: (value: string) => void; + cityFilter: string; + setCityFilter: (value: string) => void; matchSort: MatchSort; setMatchSort: (value: MatchSort) => void; selectedFamilies: string[]; @@ -62,6 +66,10 @@ export function JobTab({ setContinentFilter, countryFilter, setCountryFilter, + stateFilter, + setStateFilter, + cityFilter, + setCityFilter, matchSort, setMatchSort, selectedFamilies, @@ -86,12 +94,12 @@ export function JobTab({ return (
-
-
-

+
+
+

Vagas abertas recomendadas

-

+

Encontramos oportunidades ideais com base nas preferências do seu perfil.

@@ -100,9 +108,9 @@ export function JobTab({ type="button" onClick={() => onSearchJobs()} disabled={isSearching} - className="inline-flex h-11 items-center justify-center gap-2 rounded-md bg-primary px-5 text-sm font-bold text-primary-foreground transition-colors hover:bg-primary/90 disabled:cursor-wait disabled:opacity-60" + className="inline-flex h-10 shrink-0 items-center justify-center gap-2 rounded-lg bg-primary px-4 text-sm font-semibold text-primary-foreground shadow-sm transition-colors hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-wait disabled:opacity-60" > - +
@@ -118,8 +126,10 @@ export function JobTab({ setContinentFilter={setContinentFilter} countryFilter={countryFilter} setCountryFilter={setCountryFilter} - matchSort={matchSort} - setMatchSort={setMatchSort} + stateFilter={stateFilter} + setStateFilter={setStateFilter} + cityFilter={cityFilter} + setCityFilter={setCityFilter} selectedFamilies={selectedFamilies} setSelectedFamilies={setSelectedFamilies} familyOptions={familyOptions} @@ -136,6 +146,8 @@ export function JobTab({ = { @@ -53,6 +53,16 @@ function compareJobs( column: SortColumn, direction: SortDirection, ) { + if (column === "match") { + const score = (job: Job) => + typeof job.matchScore === "number" && Number.isFinite(job.matchScore) + ? job.matchScore + : 0; + return direction === "desc" + ? score(second) - score(first) + : score(first) - score(second); + } + if (column === "source") { return compareNullableValues( first.source.trim() || null, @@ -82,6 +92,8 @@ function compareJobs( interface JobTableProps { jobs: Job[]; + matchSort?: MatchSort; + onMatchSortChange?: (value: MatchSort) => void; onOpenJob: (job: Job) => void; onStatusChange: (jobId: string, status: JobStatus) => void; pagination?: { @@ -96,6 +108,8 @@ interface JobTableProps { export function JobTable({ jobs, + matchSort = "default", + onMatchSortChange, onOpenJob, onStatusChange, pagination, @@ -108,9 +122,22 @@ export function JobTable({ column: SortColumn; direction: SortDirection; } | null>(null); + const activeSort = useMemo( + () => + sort ?? + (matchSort !== "default" + ? { column: "match" as const, direction: matchSort } + : null), + [sort, matchSort], + ); const sortedJobs = useMemo(() => { - if (!sort) return jobs; + // Com paginação remota, Match é ordenado pela API sobre todos os resultados. + if ( + !activeSort || + (activeSort.column === "match" && (onMatchSortChange || pagination)) + ) + return jobs; return jobs .map((job, index) => ({ job, index })) @@ -119,12 +146,12 @@ export function JobTable({ compareJobs( first.job, second.job, - sort.column, - sort.direction, + activeSort.column, + activeSort.direction, ) || first.index - second.index, ) .map(({ job }) => job); - }, [jobs, sort]); + }, [jobs, activeSort, onMatchSortChange, pagination]); if (jobs.length === 0) { return ( @@ -165,6 +192,16 @@ export function JobTable({ ); const toggleSort = (column: SortColumn) => { + if (column === "match" && onMatchSortChange) { + setSort(null); + setRequestedPage(1); + onMatchSortChange( + activeSort?.column === "match" && activeSort.direction === "desc" + ? "asc" + : "desc", + ); + return; + } setSort((current) => { if (current?.column === column) { return { @@ -175,17 +212,18 @@ export function JobTable({ return { column, - direction: column === "posted" ? "desc" : "asc", + direction: column === "posted" || column === "match" ? "desc" : "asc", }; }); + if (column === "match") setRequestedPage(1); }; const sortIcon = (column: SortColumn) => { - if (sort?.column !== column) { + if (activeSort?.column !== column) { return