Merge pull request #55 from rede5/codex/review-and-refine-entire-code

feat(post-job): adicionar campos e validações estilo Careerjet na publicação de vagas (~15%)
This commit is contained in:
Tiago Yamamoto 2026-02-14 17:14:42 -03:00 committed by GitHub
commit a9f8e40d7e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 256 additions and 22 deletions

View file

@ -100,73 +100,73 @@ Mapear o que já existe no GoHorseJobs e o que ainda falta para alcançar um flu
### 1) Detalhes da vaga (escopo obrigatório)
#### Identificação da vaga
- [ ] **Título da vaga**
- [x] **Título da vaga**
- Regra: obrigatório, texto claro e conciso, até **65 caracteres**.
- Validações: bloquear excesso de pontuação, abreviações promocionais e termos de marketing (ex.: “IMPERDÍVEL!!!”).
- [ ] **Localidade da vaga**
- [x] **Localidade da vaga**
- Regra: obrigatório, **uma única localidade** por anúncio.
- Validações: cidade/município válido, sem múltiplas localidades no mesmo campo.
- [ ] **País**
- [x] **País**
- Regra: obrigatório, selecionado de lista de países.
- Dependência: país deve dirigir moeda padrão e preço do anúncio (quando aplicável no billing).
#### Modelo de contratação
- [ ] **Tipo de contrato**
- [x] **Tipo de contrato**
- Opções-alvo: `Permanent`, `Contract`, `Training`, `Temporary`, `Voluntary`, `Any`.
- [ ] **Jornada de trabalho**
- [x] **Jornada de trabalho**
- Opções-alvo: `Full-time`, `Part-time`, `Any`.
#### Salário
- [ ] **Modo de pagamento**
- [x] **Modo de pagamento**
- Opções: `Salary range` ou `Fixed salary`.
- Validação: exibir campos condicionalmente conforme o modo.
- [ ] **Moeda**
- [x] **Moeda**
- Regra: obrigatório quando salário informado.
- Opções: lista internacional (USD, EUR, BRL etc.).
- [ ] **Período do salário**
- [x] **Período do salário**
- Opções: por `hora`, `dia`, `semana`, `mês` ou `ano`.
#### Conteúdo da vaga
- [ ] **Descrição da oferta**
- [x] **Descrição da oferta**
- Regra: obrigatório, editor rico.
- Recomendação UX: suporte a parágrafos e listas para habilidades e qualificações.
#### Sobre a empresa (opcional, com ocultação)
- [ ] **Nome da empresa**
- [ ] **Site da empresa**
- [ ] **Número de empregados**
- [x] **Nome da empresa**
- [x] **Site da empresa**
- [x] **Número de empregados**
- Opções-alvo: `Self-employed`, `110`, `1150`, `51200`, `201500`, `5011000`, `10015000`, `500110000`, `10000+`.
- [ ] **Ano de fundação**
- [ ] **Descrição da empresa**
- [x] **Ano de fundação**
- [x] **Descrição da empresa**
- [ ] **Toggle “Ocultar dados da empresa”**
- Regra: quando ativo, ocultar bloco de empresa na visualização pública da vaga.
#### Recebimento de candidaturas
- [ ] **Canal de candidatura**
- [x] **Canal de candidatura**
- Opções: `E-mail`, `Link externo`, `Telefone`.
- Validação condicional:
- E-mail: validar formato RFC básico.
- Link externo: exigir URL HTTPS válida.
- Telefone: validar DDI + número.
- [ ] **Requerer envio de currículo**
- [x] **Requerer envio de currículo**
- Opções: `Obrigatório`, `Opcional`, `Não solicitado`.
- [ ] **Idioma da descrição da vaga**
- [x] **Idioma da descrição da vaga**
- Regra: obrigatório.
- Recomendação: idioma deve ser compatível com o país selecionado.
#### Extensões locais (GoHorseJobs)
- [ ] **CNPJ da empresa (Brasil)**
- [x] **CNPJ da empresa (Brasil)**
- Regra: opcional/obrigatório por política comercial.
- Validação: máscara e dígitos verificadores.
- [ ] **Benefícios** (multiselect)
- [ ] **Área de atuação** (taxonomia do portal)
- [x] **Benefícios** (multiselect)
- [x] **Área de atuação** (taxonomia do portal)
---

View file

@ -78,6 +78,7 @@ export default function PostJobPage() {
const [company, setCompany] = useState({
name: "",
email: "",
document: "",
password: "",
confirmPassword: "",
ddi: "+55",
@ -96,6 +97,7 @@ export default function PostJobPage() {
title: "",
description: "",
location: "",
country: "",
salaryMin: "",
salaryMax: "",
salaryFixed: "", // For fixed salary mode
@ -105,6 +107,14 @@ export default function PostJobPage() {
workMode: "remote",
workingHours: "",
salaryNegotiable: false, // Candidate proposes salary
descriptionLanguage: "",
applicationChannel: "email",
applicationEmail: "",
applicationUrl: "",
applicationPhone: "",
resumeRequirement: "optional",
jobCategory: "",
benefits: [] as string[],
});
const [questions, setQuestions] = useState<Question[]>([]);
@ -112,6 +122,36 @@ export default function PostJobPage() {
// Salary mode toggle: 'fixed' | 'range'
const [salaryMode, setSalaryMode] = useState<'fixed' | 'range'>('fixed');
const BENEFIT_OPTIONS = ["Plano de saúde", "Vale refeição", "Vale transporte", "Bônus", "Home office", "Gym pass"];
const JOB_CATEGORIES = ["Tecnologia", "Produto", "Dados", "Marketing", "Vendas", "Operações", "Financeiro", "RH"];
const JOB_COUNTRIES = ["BR", "PT", "US", "ES", "UK", "DE", "FR", "JP"];
const cleanCNPJ = (value: string) => value.replace(/\D/g, "");
const isValidCNPJ = (value: string) => {
const cnpj = cleanCNPJ(value);
if (cnpj.length !== 14) return false;
if (/^(\d)\1+$/.test(cnpj)) return false;
const calcCheckDigit = (base: string, weights: number[]) => {
const sum = base
.split("")
.reduce((acc, current, index) => acc + Number(current) * weights[index], 0);
const mod = sum % 11;
return mod < 2 ? 0 : 11 - mod;
};
const base12 = cnpj.slice(0, 12);
const digit1 = calcCheckDigit(base12, [5, 4, 3, 2, 9, 8, 7, 6, 5, 4, 3, 2]);
const digit2 = calcCheckDigit(`${base12}${digit1}`, [6, 5, 4, 3, 2, 9, 8, 7, 6, 5, 4, 3, 2]);
return cnpj === `${base12}${digit1}${digit2}`;
};
const isHttpsUrl = (value: string) => /^https:\/\/.+/i.test(value);
const isPhoneWithDDI = (value: string) => /^\+\d{1,3}\s?\d{8,14}$/.test(value.trim());
const formatPhoneForDisplay = (value: string) => {
// Simple formatting to just allow numbers and basic separators if needed
// For now, just pass through but maybe restrict chars?
@ -125,6 +165,12 @@ export default function PostJobPage() {
return false;
}
if (company.document && !isValidCNPJ(company.document)) {
toast.error("CNPJ inválido.");
setStep(1);
return false;
}
if (company.password !== company.confirmPassword) {
toast.error(t.errors.password_mismatch);
setStep(1); // Ensure we are on step 1 for password mismatch
@ -137,11 +183,36 @@ export default function PostJobPage() {
return false;
}
if (!job.title || !job.description) {
if (!job.title || !job.description || !job.location || !job.country || !job.descriptionLanguage) {
toast.error(t.errors.job_required);
setStep(1); // Stay on step 1 for job data errors
return false;
}
if (job.title.length > 65) {
toast.error("O título da vaga deve ter no máximo 65 caracteres.");
setStep(1);
return false;
}
if (job.applicationChannel === "email" && !job.applicationEmail) {
toast.error("Informe um e-mail para candidatura.");
setStep(1);
return false;
}
if (job.applicationChannel === "url" && !isHttpsUrl(job.applicationUrl)) {
toast.error("Informe uma URL HTTPS válida para candidatura.");
setStep(1);
return false;
}
if (job.applicationChannel === "phone" && !isPhoneWithDDI(job.applicationPhone)) {
toast.error("Informe um telefone com DDI válido (ex: +55 11999998888).");
setStep(1);
return false;
}
return true;
};
@ -160,6 +231,14 @@ export default function PostJobPage() {
toast.error(t.errors.password_length);
return;
}
if (!job.title || !job.description || !job.location || !job.country || !job.descriptionLanguage) {
toast.error("Preencha título, localidade, país, idioma e descrição da vaga.");
return;
}
if (job.title.length > 65) {
toast.error("O título da vaga deve ter no máximo 65 caracteres.");
return;
}
setStep(2);
} else if (step === 2) {
setStep(3);
@ -184,6 +263,7 @@ export default function PostJobPage() {
body: JSON.stringify({
companyName: company.name,
email: company.email,
document: cleanCNPJ(company.document) || null,
password: company.password,
phone: finalPhone,
website: company.website || null,
@ -210,7 +290,7 @@ export default function PostJobPage() {
body: JSON.stringify({
title: job.title,
description: job.description,
location: job.location,
location: `${job.location}, ${job.country}`,
// Salary logic: if negotiable, send null values
salaryMin: job.salaryNegotiable ? null : (salaryMode === 'fixed' ? (job.salaryFixed ? parseInt(job.salaryFixed) : null) : (job.salaryMin ? parseInt(job.salaryMin) : null)),
salaryMax: job.salaryNegotiable ? null : (salaryMode === 'fixed' ? (job.salaryFixed ? parseInt(job.salaryFixed) : null) : (job.salaryMax ? parseInt(job.salaryMax) : null)),
@ -222,6 +302,15 @@ export default function PostJobPage() {
workMode: job.workMode,
status: "pending",
questions: questions.length > 0 ? questions : null,
languageLevel: job.descriptionLanguage || null,
requirements: {
category: job.jobCategory || null,
resumeRequirement: job.resumeRequirement,
applicationChannel: job.applicationChannel,
},
benefits: {
selected: job.benefits,
},
}),
});
@ -337,6 +426,15 @@ export default function PostJobPage() {
</div>
</div>
<div>
<Label>CNPJ da empresa (opcional)</Label>
<Input
value={company.document}
onChange={(e) => setCompany({ ...company, document: e.target.value })}
placeholder="00.000.000/0000-00"
/>
</div>
{/* Password Field */}
<div>
<Label>{t.company.password}</Label>
@ -492,8 +590,10 @@ export default function PostJobPage() {
onChange={(e) => setJob({ ...job, title: e.target.value })}
placeholder={t.job.jobTitlePlaceholder}
className="pl-10"
maxLength={65}
/>
</div>
<p className="text-xs text-muted-foreground mt-1">{job.title.length}/65 caracteres</p>
</div>
<div>
<Label>{t.job.description}</Label>
@ -513,6 +613,34 @@ export default function PostJobPage() {
/>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<Label>País da vaga *</Label>
<select
value={job.country}
onChange={(e) => setJob({ ...job, country: e.target.value })}
className="w-full px-3 py-2 border rounded-lg bg-background"
>
<option value="">Selecione</option>
{JOB_COUNTRIES.map((country) => (
<option key={country} value={country}>{country}</option>
))}
</select>
</div>
<div>
<Label>Idioma da descrição *</Label>
<select
value={job.descriptionLanguage}
onChange={(e) => setJob({ ...job, descriptionLanguage: e.target.value })}
className="w-full px-3 py-2 border rounded-lg bg-background"
>
<option value="">Selecione</option>
<option value="pt">Português</option>
<option value="en">English</option>
<option value="es">Español</option>
</select>
</div>
</div>
{/* Salary Section */}
<div className="space-y-3">
<div className="flex items-center justify-between">
@ -646,6 +774,106 @@ export default function PostJobPage() {
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<Label>Canal de candidatura</Label>
<select
value={job.applicationChannel}
onChange={(e) => setJob({ ...job, applicationChannel: e.target.value })}
className="w-full px-3 py-2 border rounded-lg bg-background"
>
<option value="email">E-mail</option>
<option value="url">Link externo</option>
<option value="phone">Telefone</option>
</select>
</div>
<div>
<Label>Currículo</Label>
<select
value={job.resumeRequirement}
onChange={(e) => setJob({ ...job, resumeRequirement: e.target.value })}
className="w-full px-3 py-2 border rounded-lg bg-background"
>
<option value="required">Obrigatório</option>
<option value="optional">Opcional</option>
<option value="none">Não solicitado</option>
</select>
</div>
</div>
{job.applicationChannel === "email" && (
<div>
<Label>E-mail para candidatura</Label>
<Input
type="email"
value={job.applicationEmail}
onChange={(e) => setJob({ ...job, applicationEmail: e.target.value })}
placeholder="jobs@empresa.com"
/>
</div>
)}
{job.applicationChannel === "url" && (
<div>
<Label>URL externa (HTTPS)</Label>
<Input
value={job.applicationUrl}
onChange={(e) => setJob({ ...job, applicationUrl: e.target.value })}
placeholder="https://empresa.com/carreiras"
/>
</div>
)}
{job.applicationChannel === "phone" && (
<div>
<Label>Telefone com DDI</Label>
<Input
value={job.applicationPhone}
onChange={(e) => setJob({ ...job, applicationPhone: e.target.value })}
placeholder="+55 11999998888"
/>
</div>
)}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<Label>Área de atuação</Label>
<select
value={job.jobCategory}
onChange={(e) => setJob({ ...job, jobCategory: e.target.value })}
className="w-full px-3 py-2 border rounded-lg bg-background"
>
<option value="">Selecione</option>
{JOB_CATEGORIES.map((category) => (
<option key={category} value={category}>{category}</option>
))}
</select>
</div>
<div>
<Label>Benefícios</Label>
<div className="grid grid-cols-2 gap-2 text-sm">
{BENEFIT_OPTIONS.map((benefit) => {
const checked = job.benefits.includes(benefit);
return (
<label key={benefit} className="flex items-center gap-2">
<input
type="checkbox"
checked={checked}
onChange={(e) => {
const nextBenefits = e.target.checked
? [...job.benefits, benefit]
: job.benefits.filter((item) => item !== benefit);
setJob({ ...job, benefits: nextBenefits });
}}
/>
{benefit}
</label>
);
})}
</div>
</div>
</div>
<Button onClick={handleNext} className="w-full">
{t.buttons.next}
</Button>
@ -692,6 +920,8 @@ export default function PostJobPage() {
</h3>
<p><strong>{t.common.title}:</strong> {job.title}</p>
<p><strong>{t.common.location}:</strong> {job.location || "Não informado"}</p>
<p><strong>País:</strong> {job.country || "Não informado"}</p>
<p><strong>Idioma:</strong> {job.descriptionLanguage || "Não informado"}</p>
<p><strong>{t.common.salary}:</strong> {
job.salaryNegotiable
? t.job.salaryNegotiable
@ -700,6 +930,10 @@ export default function PostJobPage() {
: (job.salaryMin && job.salaryMax ? `${getCurrencySymbol(job.currency)} ${job.salaryMin} - ${job.salaryMax} ${getSalaryPeriodLabel(job.salaryType)}` : t.job.salaryNegotiable)
}</p>
<p><strong>Perguntas Personalizadas:</strong> {questions.length}</p>
<p><strong>Canal de candidatura:</strong> {job.applicationChannel}</p>
<p><strong>Currículo:</strong> {job.resumeRequirement}</p>
<p><strong>Área:</strong> {job.jobCategory || "Não informado"}</p>
<p><strong>Benefícios:</strong> {job.benefits.length > 0 ? job.benefits.join(", ") : "Não informado"}</p>
<p><strong>{t.common.type}:</strong> {
(job.employmentType ? (t.options.contract[job.employmentType as keyof typeof t.options.contract] || job.employmentType) : t.options.any)
} / {