gohorsejobs/frontend/src/app/vagas/[id]/candidatura/page.tsx
Tiago Yamamoto 9ee9f6855c feat: implementar múltiplas features
Backend:
- Password reset flow (forgot/reset endpoints, tokens table)
- Profile management (PUT /users/me, skills, experience, education)
- Tickets system (CRUD, messages, stats)
- Activity logs (list, stats)
- Document validator (CNPJ, CPF, EIN support)
- Input sanitizer (XSS prevention)
- Full-text search em vagas (plainto_tsquery)
- Filtros avançados (location, salary, workMode)
- Ordenação (date, salary, relevance)

Frontend:
- Forgot/Reset password pages
- Candidate profile edit page
- Sanitize utilities (sanitize.ts)

Backoffice:
- TicketsModule proxy
- ActivityLogsModule proxy
- Dockerfile otimizado (multi-stage, non-root, healthcheck)

Migrations:
- 013: Profile fields to users
- 014: Password reset tokens
- 015: Tickets table
- 016: Activity logs table
2025-12-27 11:19:47 -03:00

663 lines
No EOL
25 KiB
TypeScript

"use client";
import { useState, use } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { motion, AnimatePresence } from "framer-motion";
import {
ChevronRight,
ChevronLeft,
Upload,
CheckCircle2,
Briefcase,
FileText,
User,
MessageSquare,
Save,
ArrowLeft,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Checkbox } from "@/components/ui/checkbox";
import { Progress } from "@/components/ui/progress";
import { Separator } from "@/components/ui/separator";
import { Navbar } from "@/components/navbar";
import { Footer } from "@/components/footer";
import { useNotify } from "@/contexts/notification-context";
import { mockJobs } from "@/lib/mock-data";
import { storageApi, applicationsApi } from "@/lib/api";
// Definição Dos Passos
const steps = [
{ id: 1, title: "Dados Pessoais", icon: User },
{ id: 2, title: "Currículo e Documentos", icon: FileText },
{ id: 3, title: "Experiência", icon: Briefcase },
{ id: 4, title: "Perguntas Adicionais", icon: MessageSquare },
];
export const runtime = 'edge';
export default function JobApplicationPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = use(params);
const router = useRouter();
const notify = useNotify();
const [currentStep, setCurrentStep] = useState(1);
const [isSubmitting, setIsSubmitting] = useState(false);
// Achar os detalhes da vaga
const job = mockJobs.find((j) => j.id === id) || mockJobs[0];
// Estado do formulário
const [formData, setFormData] = useState({
// Etapa 1
fullName: "",
email: "",
phone: "",
linkedin: "",
privacyAccepted: false,
// Etapa 2
resume: null as File | null,
coverLetter: "",
portfolioUrl: "",
// Etapa 3
salaryExpectation: "",
hasExperience: "",
// Etapa 4
whyUs: "",
availability: [] as string[],
});
const handleInputChange = (field: string, value: any) => {
setFormData((prev) => ({ ...prev, [field]: value }));
};
const validateStep = (step: number) => {
switch (step) {
case 1:
if (!formData.fullName || !formData.email || !formData.phone) {
notify.error(
"Campos obrigatórios",
"Por favor, preencha todos os campos obrigatórios."
);
return false;
}
if (!formData.email.includes("@")) {
notify.error(
"E-mail inválido",
"Por favor, insira um e-mail válido."
);
return false;
}
if (!formData.privacyAccepted) {
notify.error(
"Termos de Privacidade",
"Você precisa aceitar a política de privacidade para continuar."
);
return false;
}
return true;
case 2:
return true;
case 3:
if (!formData.salaryExpectation || !formData.hasExperience) {
notify.error(
"Campos obrigatórios",
"Por favor, responda todas as perguntas."
);
return false;
}
return true;
case 4:
if (!formData.whyUs || formData.availability.length === 0) {
notify.error(
"Campos obrigatórios",
"Por favor, preencha o motivo e selecione pelo menos uma disponibilidade."
);
return false;
}
return true;
default:
return true;
}
};
const handleNext = () => {
if (validateStep(currentStep)) {
if (currentStep < steps.length) {
setCurrentStep((prev) => prev + 1);
window.scrollTo(0, 0);
} else {
handleSubmit();
}
}
};
const handleBack = () => {
if (currentStep > 1) {
setCurrentStep((prev) => prev - 1);
window.scrollTo(0, 0);
}
};
const handleSubmit = async () => {
try {
setIsSubmitting(true);
let resumeUrl = "";
// 1. Upload Curriculo
if (formData.resume) {
try {
const { uploadUrl, publicUrl } = await storageApi.getUploadUrl(
formData.resume.name,
formData.resume.type
);
await storageApi.uploadFile(uploadUrl, formData.resume);
resumeUrl = publicUrl;
} catch (err) {
console.error("Upload error:", err);
notify.error("Erro no upload", "Não foi possível enviar seu currículo. Tente novamente.");
setIsSubmitting(false);
return;
}
}
// 2. Create Application
await applicationsApi.create({
jobId: Number(job.id),
name: formData.fullName,
email: formData.email,
phone: formData.phone,
message: formData.coverLetter || formData.whyUs, // Using cover letter or whyUs as message
resumeUrl: resumeUrl,
documents: {
linkedin: formData.linkedin,
portfolio: formData.portfolioUrl,
salaryExpectation: formData.salaryExpectation,
availability: formData.availability,
whyUs: formData.whyUs
}
});
notify.success(
"Candidatura enviada com sucesso!",
`Boa sorte! Sua candidatura para ${job.title} foi recebida.`
);
router.push("/dashboard/candidato/candidaturas"); // Redirecionar para dashboard correto
} catch (error) {
console.error("Application error:", error);
notify.error("Erro ao enviar", "Ocorreu um erro ao processar sua candidatura.");
} finally {
setIsSubmitting(false);
}
};
const handleSaveDraft = () => {
notify.info(
"Rascunho salvo",
"Você pode continuar sua candidatura mais tarde."
);
};
const progress = (currentStep / steps.length) * 100;
if (!job) return null;
return (
<div className="min-h-screen flex flex-col bg-muted/30">
<Navbar />
<main className="flex-1 py-8">
<div className="container mx-auto px-4 sm:px-6 lg:px-8 max-w-4xl">
{/* Header */}
<div className="mb-8">
<Link
href={`/vagas/${id}`}
className="inline-flex items-center text-sm text-muted-foreground hover:text-primary mb-4 transition-colors"
>
<ArrowLeft className="mr-2 h-4 w-4" />
Voltar para detalhes da vaga
</Link>
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
<div>
<h1 className="text-2xl md:text-3xl font-bold text-foreground">
Candidatura: {job.title}
</h1>
<p className="text-muted-foreground mt-1">
{job.company} {job.location}
</p>
</div>
<div className="text-sm font-medium bg-primary/10 text-primary px-3 py-1 rounded-full self-start md:self-center">
Tempo estimado: 5 min
</div>
</div>
</div>
{/* Progresso das etapas */}
<div className="mb-8">
<div className="flex justify-between mb-2">
<span className="text-sm font-medium text-muted-foreground">
Etapa {currentStep} de {steps.length}:{" "}
<span className="text-foreground">
{steps[currentStep - 1].title}
</span>
</span>
<span className="text-sm font-medium text-primary">
{Math.round(progress)}%
</span>
</div>
<Progress value={progress} className="h-2" />
{/* Indicador de etapas (DESKTOP) */}
<div className="hidden md:flex justify-between mt-4 px-2">
{steps.map((step) => {
const Icon = step.icon;
const isActive = step.id === currentStep;
const isCompleted = step.id < currentStep;
return (
<div
key={step.id}
className={`flex flex-col items-center gap-2 ${isActive
? "text-primary"
: isCompleted
? "text-primary/60"
: "text-muted-foreground"
}`}
>
<div
className={`
w-8 h-8 rounded-full flex items-center justify-center border-2 transition-colors
${isActive
? "border-primary bg-primary text-primary-foreground"
: isCompleted
? "border-primary bg-primary/10 text-primary"
: "border-muted-foreground/30 bg-background"
}
`}
>
{isCompleted ? (
<CheckCircle2 className="h-5 w-5" />
) : (
<Icon className="h-4 w-4" />
)}
</div>
<span className="text-xs font-medium">{step.title}</span>
</div>
);
})}
</div>
</div>
{/* Conteúdo do formulário */}
<div className="grid gap-6">
<AnimatePresence mode="wait">
<motion.div
key={currentStep}
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -20 }}
transition={{ duration: 0.3 }}
>
<Card className="border-t-4 border-t-primary">
<CardHeader>
<CardTitle>{steps[currentStep - 1].title}</CardTitle>
<CardDescription>
Preencha as informações abaixo para continuar.
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{/* Etapa 1: Dados Pessoais */}
{currentStep === 1 && (
<div className="space-y-4">
<div className="grid md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="fullName">Nome Completo *</Label>
<Input
id="fullName"
placeholder="Seu nome completo"
value={formData.fullName}
onChange={(e) =>
handleInputChange("fullName", e.target.value)
}
/>
</div>
<div className="space-y-2">
<Label htmlFor="email">E-mail *</Label>
<Input
id="email"
type="email"
placeholder="seu@email.com"
value={formData.email}
onChange={(e) =>
handleInputChange("email", e.target.value)
}
/>
</div>
</div>
<div className="grid md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="phone">Telefone / WhatsApp *</Label>
<Input
id="phone"
placeholder="(00) 00000-0000"
value={formData.phone}
onChange={(e) =>
handleInputChange("phone", e.target.value)
}
/>
</div>
<div className="space-y-2">
<Label htmlFor="linkedin">LinkedIn (URL)</Label>
<Input
id="linkedin"
placeholder="linkedin.com/in/seu-perfil"
value={formData.linkedin}
onChange={(e) =>
handleInputChange("linkedin", e.target.value)
}
/>
</div>
</div>
<div className="flex items-center space-x-2 pt-4">
<Checkbox
id="privacy"
checked={formData.privacyAccepted}
onCheckedChange={(checked) =>
handleInputChange("privacyAccepted", checked)
}
/>
<Label
htmlFor="privacy"
className="text-sm font-normal text-muted-foreground"
>
Li e concordo com a{" "}
<a href="#" className="text-primary underline">
Política de Privacidade
</a>{" "}
e autorizo o tratamento dos meus dados para fins de
recrutamento.
</Label>
</div>
</div>
)}
{/* Etapa 2: Dccumentos */}
{currentStep === 2 && (
<div className="space-y-6">
<div className="space-y-3">
<Label>Currículo (CV) *</Label>
<div className="border-2 border-dashed border-muted-foreground/25 rounded-lg p-8 text-center hover:bg-muted/50 transition-colors cursor-pointer">
<div className="flex flex-col items-center gap-2">
<div className="p-3 bg-primary/10 rounded-full text-primary">
<Upload className="h-6 w-6" />
</div>
<div className="space-y-1">
<p className="text-sm font-medium">
Clique para fazer upload ou arraste o arquivo
</p>
<p className="text-xs text-muted-foreground">
PDF, DOCX ou TXT (Máx. 5MB)
</p>
</div>
</div>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="portfolio">
Portfólio / Site Pessoal (Opcional)
</Label>
<Input
id="portfolio"
placeholder="https://..."
value={formData.portfolioUrl}
onChange={(e) =>
handleInputChange("portfolioUrl", e.target.value)
}
/>
</div>
<div className="space-y-2">
<Label htmlFor="coverLetter">
Carta de Apresentação (Opcional)
</Label>
<Textarea
id="coverLetter"
placeholder="Escreva uma breve apresentação sobre você..."
className="min-h-[150px]"
value={formData.coverLetter}
onChange={(e) =>
handleInputChange("coverLetter", e.target.value)
}
/>
</div>
</div>
)}
{/* Etapa 3: Experiências */}
{currentStep === 3 && (
<div className="space-y-6">
<div className="space-y-2">
<Label htmlFor="salary">Pretensão Salarial *</Label>
<Select
value={formData.salaryExpectation}
onValueChange={(val) =>
handleInputChange("salaryExpectation", val)
}
>
<SelectTrigger>
<SelectValue placeholder="Selecione uma faixa" />
</SelectTrigger>
<SelectContent>
<SelectItem value="ate-3k">
Até R$ 3.000
</SelectItem>
<SelectItem value="3k-5k">
R$ 3.000 - R$ 5.000
</SelectItem>
<SelectItem value="5k-8k">
R$ 5.000 - R$ 8.000
</SelectItem>
<SelectItem value="8k-12k">
R$ 8.000 - R$ 12.000
</SelectItem>
<SelectItem value="12k-plus">
Acima de R$ 12.000
</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-3">
<Label>
Você possui a experiência mínima requisitada para a
vaga? *
</Label>
<div className="flex gap-4">
<div className="flex items-center space-x-2 border p-3 rounded-md flex-1 hover:bg-muted/50 cursor-pointer">
<input
type="radio"
name="experience"
id="exp-yes"
className="accent-primary h-4 w-4"
checked={formData.hasExperience === "yes"}
onChange={() =>
handleInputChange("hasExperience", "yes")
}
/>
<Label
htmlFor="exp-yes"
className="cursor-pointer flex-1"
>
Sim, possuo
</Label>
</div>
<div className="flex items-center space-x-2 border p-3 rounded-md flex-1 hover:bg-muted/50 cursor-pointer">
<input
type="radio"
name="experience"
id="exp-no"
className="accent-primary h-4 w-4"
checked={formData.hasExperience === "no"}
onChange={() =>
handleInputChange("hasExperience", "no")
}
/>
<Label
htmlFor="exp-no"
className="cursor-pointer flex-1"
>
Não possuo
</Label>
</div>
</div>
</div>
</div>
)}
{/* Etapa 4: Adicional */}
{currentStep === 4 && (
<div className="space-y-6">
<div className="space-y-2">
<Label htmlFor="whyUs">
Por que você quer trabalhar na {job.company}? *
</Label>
<Textarea
id="whyUs"
placeholder="Conte-nos o que te atrai na nossa empresa e nesta vaga..."
className="min-h-[150px]"
maxLength={1000}
value={formData.whyUs}
onChange={(e) =>
handleInputChange("whyUs", e.target.value)
}
/>
<div className="text-xs text-right text-muted-foreground">
{formData.whyUs.length}/1000 caracteres
</div>
</div>
<div className="space-y-3">
<Label>Disponibilidade *</Label>
<div className="grid gap-2">
{[
"Trabalho Presencial",
"Trabalho Remoto",
"Viagens",
"Início Imediato",
].map((item) => (
<div
key={item}
className="flex items-center space-x-2"
>
<Checkbox
id={`avail-${item}`}
checked={formData.availability.includes(item)}
onCheckedChange={(checked) => {
if (checked) {
handleInputChange("availability", [
...formData.availability,
item,
]);
} else {
handleInputChange(
"availability",
formData.availability.filter(
(i) => i !== item
)
);
}
}}
/>
<Label htmlFor={`avail-${item}`}>{item}</Label>
</div>
))}
</div>
</div>
</div>
)}
</CardContent>
<CardFooter className="flex justify-between border-t pt-6">
<Button
variant="outline"
onClick={handleBack}
disabled={currentStep === 1 || isSubmitting}
>
<ChevronLeft className="mr-2 h-4 w-4" />
Voltar
</Button>
<div className="flex gap-2">
<Button
variant="ghost"
onClick={handleSaveDraft}
disabled={isSubmitting}
className="hidden sm:flex"
>
<Save className="mr-2 h-4 w-4" />
Salvar Rascunho
</Button>
<Button
onClick={handleNext}
disabled={isSubmitting}
className="min-w-[120px]"
>
{isSubmitting ? (
"Enviando..."
) : currentStep === steps.length ? (
<>
Enviar Candidatura{" "}
<CheckCircle2 className="ml-2 h-4 w-4" />
</>
) : (
<>
Próxima Etapa{" "}
<ChevronRight className="ml-2 h-4 w-4" />
</>
)}
</Button>
</div>
</CardFooter>
</Card>
</motion.div>
</AnimatePresence>
</div>
</div>
</main>
<Footer />
</div>
);
}