diff --git a/docs/CAREERJET_GAP_ANALYSIS.md b/docs/CAREERJET_GAP_ANALYSIS.md index 4ad7c04..a6ac2c7 100644 --- a/docs/CAREERJET_GAP_ANALYSIS.md +++ b/docs/CAREERJET_GAP_ANALYSIS.md @@ -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) --- diff --git a/frontend/src/app/post-job/page.tsx b/frontend/src/app/post-job/page.tsx index 0625513..2e30ba4 100644 --- a/frontend/src/app/post-job/page.tsx +++ b/frontend/src/app/post-job/page.tsx @@ -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([]); @@ -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() { +
+ + setCompany({ ...company, document: e.target.value })} + placeholder="00.000.000/0000-00" + /> +
+ {/* Password Field */}
@@ -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} />
+

{job.title.length}/65 caracteres

@@ -513,6 +613,34 @@ export default function PostJobPage() { />
+
+
+ + +
+
+ + +
+
{/* Salary Section */}
@@ -646,6 +774,106 @@ export default function PostJobPage() {
+
+
+ + +
+
+ + +
+
+ + {job.applicationChannel === "email" && ( +
+ + setJob({ ...job, applicationEmail: e.target.value })} + placeholder="jobs@empresa.com" + /> +
+ )} + + {job.applicationChannel === "url" && ( +
+ + setJob({ ...job, applicationUrl: e.target.value })} + placeholder="https://empresa.com/carreiras" + /> +
+ )} + + {job.applicationChannel === "phone" && ( +
+ + setJob({ ...job, applicationPhone: e.target.value })} + placeholder="+55 11999998888" + /> +
+ )} + +
+
+ + +
+
+ +
+ {BENEFIT_OPTIONS.map((benefit) => { + const checked = job.benefits.includes(benefit); + return ( + + ); + })} +
+
+
+ @@ -692,6 +920,8 @@ export default function PostJobPage() {

{t.common.title}: {job.title}

{t.common.location}: {job.location || "Não informado"}

+

País: {job.country || "Não informado"}

+

Idioma: {job.descriptionLanguage || "Não informado"}

{t.common.salary}: { 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) }

Perguntas Personalizadas: {questions.length}

+

Canal de candidatura: {job.applicationChannel}

+

Currículo: {job.resumeRequirement}

+

Área: {job.jobCategory || "Não informado"}

+

Benefícios: {job.benefits.length > 0 ? job.benefits.join(", ") : "Não informado"}

{t.common.type}: { (job.employmentType ? (t.options.contract[job.employmentType as keyof typeof t.options.contract] || job.employmentType) : t.options.any) } / {