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:
commit
a9f8e40d7e
2 changed files with 256 additions and 22 deletions
|
|
@ -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`, `1–10`, `11–50`, `51–200`, `201–500`, `501–1000`, `1001–5000`, `5001–10000`, `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)
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
} / {
|
||||
|
|
|
|||
Loading…
Reference in a new issue