Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 70 additions & 4 deletions app/(dashboard)/jobs/[id]/page.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,71 @@
export default function JobDetailPage() {
return <div>Detalhe da vaga placeholder</div>;
}
import { notFound } from 'next/navigation';
import { getJobById } from '@/features/jobs/server/get-jobs';

export default async function JobDetailPage({ params }: { params: { id: string } }) {
const job = await getJobById(params.id);

if (!job) {
notFound();
}

return (
<main className="container mx-auto px-4 py-8 max-w-4xl">
<div className="bg-white border border-gray-200 rounded-lg p-6 shadow-sm">
<div className="flex justify-between items-start mb-4">
<div>
<h1 className="text-2xl font-bold text-gray-900">{job.title}</h1>
<p className="text-lg text-gray-600">
{job.company_name} • {job.location}
</p>
</div>
<a
href={job.application_url}
target="_blank"
rel="noopener noreferrer"
className="bg-blue-600 text-white font-semibold px-4 py-2 rounded-md hover:bg-blue-700 transition-colors"
>
Candidatar-se
</a>
</div>

<div className="flex gap-2 my-4">
<span className="text-xs bg-blue-100 text-blue-800 px-2.5 py-1 rounded font-medium">
{job.modality}
</span>
<span className="text-xs bg-gray-100 text-gray-800 px-2.5 py-1 rounded font-medium">
{job.seniority}
</span>
</div>

// TODO: implementar página de detalhe da vaga com informações e CTA.
<hr className="my-6" />

<div className="mb-6">
<h2 className="text-lg font-semibold mb-2">Descrição da Vaga</h2>
<p className="text-gray-700 whitespace-pre-line">{job.description}</p>
</div>

{job.requirements.length > 0 && (
<div className="mb-6">
<h2 className="text-lg font-semibold mb-2">Requisitos</h2>
<ul className="list-disc list-inside text-gray-700 space-y-1">
{job.requirements.map((req, index) => (
<li key={index}>{req}</li>
))}
</ul>
</div>
)}

<div>
<h2 className="text-lg font-semibold mb-2">Tecnologias</h2>
<div className="flex flex-wrap gap-2">
{job.technologies.map((tech) => (
<span key={tech} className="bg-gray-100 text-gray-800 text-sm px-3 py-1 rounded-full">
{tech}
</span>
))}
</div>
</div>
</div>
</main>
);
}
46 changes: 43 additions & 3 deletions app/(dashboard)/jobs/page.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,45 @@
export default function JobsPage() {
return <div>Lista de vagas placeholder</div>;
import { Suspense } from 'react';
import { getJobs } from '@/features/jobs/server/get-jobs';
import { JobList } from '@/features/jobs/components/JobList';
import { JobFilters } from '@/features/jobs/components/JobFilters';
import { JobModality, SeniorityLevel } from '@/features/jobs/types';

interface JobsPageProps {
searchParams: Promise<{
search?: string;
modality?: JobModality | 'ALL';
seniority?: SeniorityLevel | 'ALL';
tech?: string;
}>;
}

// TODO: implementar listagem, filtros e paginação de vagas.
export default async function JobsPage({ searchParams }: JobsPageProps) {
const filters = await searchParams;

// Busca vagas filtradas no lado do servidor
const jobs = await getJobs({
search: filters.search,
modality: filters.modality,
seniority: filters.seniority,
tech: filters.tech,
});

return (
<main className="container mx-auto px-4 py-8 max-w-6xl">
<div className="mb-6">
<h1 className="text-3xl font-bold text-gray-900">Vagas da Comunidade</h1>
<p className="text-gray-600 mt-1">
Encontre e filtre vagas curadas pelo GDG Lauro de Freitas.
</p>
</div>

{/* Componente Client-Side para manipular os filtros de busca */}
<Suspense fallback={<div className="h-32 bg-gray-100 rounded-lg animate-pulse mb-6" />}>
<JobFilters />
</Suspense>

{/* Lista das vagas retornadas */}
<JobList jobs={jobs} />
</main>
);
}
2 changes: 1 addition & 1 deletion app/(dashboard)/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ export default function DashboardLayout({ children }: { children: React.ReactNod

const handleLogout = async () => {
await logout();
router.push('/login');
router.push('/');
};

return (
Expand Down
11 changes: 0 additions & 11 deletions app/api/jobs/[id]/route.ts

This file was deleted.

7 changes: 0 additions & 7 deletions app/api/jobs/route.ts

This file was deleted.

9 changes: 9 additions & 0 deletions features/jobs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,12 @@ Responsável pela experiência principal de vagas e busca.
- Definir filtros e paginação
- Criar cards e páginas de detalhe
- Modelar curadoria e submissão de vagas

## Notas

- `app/api/jobs/route.ts`, `app/api/jobs/[id]/route.ts` e `features/jobs/hooks/useJobs.ts`
foram removidos por serem código morto: `app/(dashboard)/jobs/page.tsx` e
`app/(dashboard)/jobs/[id]/page.tsx` chamam `getJobs`/`getJobById` direto no
servidor, sem passar por HTTP. Reative essas rotas (e o hook) somente se
surgir um consumidor real via HTTP — um client component, um app mobile ou
um webhook externo.
46 changes: 46 additions & 0 deletions features/jobs/components/JobCard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import Link from 'next/link';
import { Job } from '../types';

interface JobCardProps {
job: Job;
}

export function JobCard({ job }: JobCardProps) {
return (
<div className="border border-gray-200 rounded-lg p-5 hover:shadow-md transition-shadow bg-white flex flex-col justify-between">
<div>
<div className="flex items-center justify-between mb-2">
<span className="text-xs font-semibold px-2.5 py-0.5 rounded bg-blue-100 text-blue-800">
{job.modality}
</span>
<span className="text-xs text-gray-500">{job.seniority}</span>
</div>

<h3 className="text-lg font-bold text-gray-900 mb-1">{job.title}</h3>
<p className="text-sm text-gray-600 mb-3">
{job.company_name} • {job.location}
</p>

<div className="flex flex-wrap gap-1.5 mb-4">
{job.technologies.map((tech) => (
<span key={tech} className="text-xs bg-gray-100 text-gray-700 px-2 py-1 rounded">
{tech}
</span>
))}
</div>
</div>

<div className="pt-3 border-t border-gray-100 flex justify-between items-center">
<span className="text-xs text-gray-400">
{new Date(job.created_at).toLocaleDateString('pt-BR')}
</span>
<Link
href={`/jobs/${job.id}`}
className="text-sm font-medium text-blue-600 hover:text-blue-800"
>
Ver detalhes →
</Link>
</div>
</div>
);
}
145 changes: 145 additions & 0 deletions features/jobs/components/JobFilters.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
'use client';

import { usePathname, useRouter, useSearchParams } from 'next/navigation';
import { useCallback, useState, useTransition } from 'react';
import { JobModality, SeniorityLevel } from '../types';

export function JobFilters() {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const [isPending, startTransition] = useTransition();

const [search, setSearch] = useState(searchParams.get('search') || '');
const [modality, setModality] = useState<string>(searchParams.get('modality') || 'ALL');
const [seniority, setSeniority] = useState<string>(searchParams.get('seniority') || 'ALL');
const [tech, setTech] = useState(searchParams.get('tech') || '');

// Sincroniza as alterações com a URL da página
const applyFilters = useCallback(
(newFilters: { search?: string; modality?: string; seniority?: string; tech?: string }) => {
const params = new URLSearchParams(searchParams.toString());

Object.entries(newFilters).forEach(([key, value]) => {
if (value && value !== 'ALL') {
params.set(key, value);
} else {
params.delete(key);
}
});

startTransition(() => {
router.push(`${pathname}?${params.toString()}`);
});
},
[pathname, router, searchParams],
);

const handleSearchSubmit = (e: React.FormEvent) => {
e.preventDefault();
applyFilters({ search, modality, seniority, tech });
};

const handleReset = () => {
setSearch('');
setModality('ALL');
setSeniority('ALL');
setTech('');
startTransition(() => {
router.push(pathname);
});
};

return (
<form
onSubmit={handleSearchSubmit}
className="bg-white border border-gray-200 rounded-lg p-4 mb-6 shadow-sm"
>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-4">
{/* Busca por Texto (Título / Empresa) */}
<div>
<label className="block text-xs font-semibold text-gray-700 mb-1">
Buscar por palavra-chave
</label>
<input
type="text"
placeholder="Ex: React, GDG, Frontend..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-full text-sm border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>

{/* Filtro de Modalidade */}
<div>
<label className="block text-xs font-semibold text-gray-700 mb-1">Modalidade</label>
<select
value={modality}
onChange={(e) => {
const val = e.target.value;
setModality(val);
applyFilters({ search, modality: val, seniority, tech });
}}
className="w-full text-sm border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white"
>
<option value="ALL">Todas as modalidades</option>
<option value="REMOTE">Remoto</option>
<option value="HYBRID">Híbrido</option>
<option value="ON_SITE">Presencial</option>
</select>
</div>

{/* Filtro de Senioridade */}
<div>
<label className="block text-xs font-semibold text-gray-700 mb-1">
Nível / Senioridade
</label>
<select
value={seniority}
onChange={(e) => {
const val = e.target.value;
setSeniority(val);
applyFilters({ search, modality, seniority: val, tech });
}}
className="w-full text-sm border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white"
>
<option value="ALL">Todos os níveis</option>
<option value="JUNIOR">Júnior</option>
<option value="PLENO">Pleno</option>
<option value="SENIOR">Sênior</option>
<option value="STAFF">STAFF</option>
</select>
</div>

{/* Filtro por Tecnologia Especifica */}
<div>
<label className="block text-xs font-semibold text-gray-700 mb-1">Tecnologia/Stack</label>
<input
type="text"
placeholder="Ex: TypeScript, Python..."
value={tech}
onChange={(e) => setTech(e.target.value)}
className="w-full text-sm border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
</div>

<div className="flex justify-end gap-2 border-t border-gray-100 pt-3">
<button
type="button"
onClick={handleReset}
className="text-xs font-medium text-gray-500 hover:text-gray-800 px-3 py-1.5 rounded transition-colors"
>
Limpar Filtros
</button>
<button
type="submit"
disabled={isPending}
className="bg-blue-600 hover:bg-blue-700 text-white text-xs font-semibold px-4 py-1.5 rounded transition-colors disabled:opacity-50"
>
{isPending ? 'Filtrando...' : 'Aplicar Filtros'}
</button>
</div>
</form>
);
}
37 changes: 37 additions & 0 deletions features/jobs/components/JobList.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { Job } from '../types';
import { JobCard } from './JobCard';

interface JobListProps {
jobs: Job[];
loading?: boolean;
}

export function JobList({ jobs, loading }: JobListProps) {
if (loading) {
return (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{[1, 2, 3].map((n) => (
<div key={n} className="h-48 bg-gray-100 rounded-lg animate-pulse p-4" />
))}
</div>
);
}

if (jobs.length === 0) {
return (
<div className="text-center py-12 bg-gray-50 rounded-lg border border-dashed border-gray-300">
<p className="text-gray-500 font-medium">
Nenhuma vaga encontrada para os filtros selecionados.
</p>
</div>
);
}

return (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{jobs.map((job) => (
<JobCard key={job.id} job={job} />
))}
</div>
);
}
Loading
Loading