feat(jobs): add public job posting page
- Created /register/job page with same layout as company registration - Split panel design: info panel on left, form on right - Two-step form: job details, then salary & company selection - Uses same styling and animations as company registration
This commit is contained in:
parent
73967ca52b
commit
aa97d86d0e
1 changed files with 472 additions and 0 deletions
472
frontend/src/app/register/job/page.tsx
Normal file
472
frontend/src/app/register/job/page.tsx
Normal file
|
|
@ -0,0 +1,472 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import Link from "next/link";
|
||||||
|
import Image from "next/image";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
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 {
|
||||||
|
Briefcase,
|
||||||
|
Building2,
|
||||||
|
ArrowLeft,
|
||||||
|
MapPin,
|
||||||
|
DollarSign,
|
||||||
|
Clock,
|
||||||
|
Loader2,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
import { useTranslation } from "@/lib/i18n";
|
||||||
|
import { LanguageSwitcher } from "@/components/language-switcher";
|
||||||
|
import { jobsApi, adminCompaniesApi, type CreateJobPayload, type AdminCompany } from "@/lib/api";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
export default function PublicPostJobPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [companies, setCompanies] = useState<AdminCompany[]>([]);
|
||||||
|
const [loadingCompanies, setLoadingCompanies] = useState(true);
|
||||||
|
const [currentStep, setCurrentStep] = useState(1);
|
||||||
|
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
title: "",
|
||||||
|
description: "",
|
||||||
|
location: "",
|
||||||
|
salaryMin: "",
|
||||||
|
salaryMax: "",
|
||||||
|
salaryType: "monthly",
|
||||||
|
currency: "BRL",
|
||||||
|
employmentType: "",
|
||||||
|
workingHours: "",
|
||||||
|
companyId: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const loadCompanies = async () => {
|
||||||
|
try {
|
||||||
|
setLoadingCompanies(true);
|
||||||
|
const data = await adminCompaniesApi.list(undefined, 1, 100);
|
||||||
|
setCompanies(data.data ?? []);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to load companies:", error);
|
||||||
|
} finally {
|
||||||
|
setLoadingCompanies(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
loadCompanies();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const updateField = (field: string, value: string) => {
|
||||||
|
setFormData((prev) => ({ ...prev, [field]: value }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const canSubmit = () => {
|
||||||
|
return (
|
||||||
|
formData.title.length >= 5 &&
|
||||||
|
formData.description.length >= 20 &&
|
||||||
|
formData.companyId !== ""
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!canSubmit()) {
|
||||||
|
toast.error("Please fill in all required fields");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const payload: CreateJobPayload = {
|
||||||
|
companyId: formData.companyId,
|
||||||
|
title: formData.title,
|
||||||
|
description: formData.description,
|
||||||
|
location: formData.location || undefined,
|
||||||
|
employmentType: formData.employmentType as CreateJobPayload["employmentType"] || undefined,
|
||||||
|
salaryMin: formData.salaryMin ? parseFloat(formData.salaryMin) : undefined,
|
||||||
|
salaryMax: formData.salaryMax ? parseFloat(formData.salaryMax) : undefined,
|
||||||
|
salaryType: formData.salaryType as CreateJobPayload["salaryType"] || undefined,
|
||||||
|
currency: formData.currency as CreateJobPayload["currency"] || undefined,
|
||||||
|
workingHours: formData.workingHours || undefined,
|
||||||
|
status: "draft",
|
||||||
|
};
|
||||||
|
|
||||||
|
await jobsApi.create(payload);
|
||||||
|
toast.success("Job posted successfully! It will be reviewed soon.");
|
||||||
|
router.push("/jobs");
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("Failed to post job:", error);
|
||||||
|
toast.error(error.message || "Failed to post job. Please try again.");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const nextStep = () => {
|
||||||
|
if (currentStep < 2) setCurrentStep(currentStep + 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const prevStep = () => {
|
||||||
|
if (currentStep > 1) setCurrentStep(currentStep - 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const stepVariants = {
|
||||||
|
hidden: { opacity: 0, x: 20 },
|
||||||
|
visible: { opacity: 1, x: 0 },
|
||||||
|
exit: { opacity: 0, x: -20 },
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gradient-to-br from-background to-muted/20 flex">
|
||||||
|
{/* Left Panel - Information */}
|
||||||
|
<div className="hidden lg:flex lg:flex-1 bg-gradient-to-br from-primary to-primary/80 p-8 flex-col justify-center items-center text-primary-foreground relative">
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
className="max-w-md text-center"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-center gap-3 mb-8">
|
||||||
|
<Image
|
||||||
|
src="/logohorse.png"
|
||||||
|
alt="GoHorse Jobs"
|
||||||
|
width={80}
|
||||||
|
height={80}
|
||||||
|
className="rounded-lg"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1 className="text-4xl font-bold mb-4">Post a Job</h1>
|
||||||
|
|
||||||
|
<p className="text-lg opacity-90 leading-relaxed mb-6">
|
||||||
|
Reach thousands of qualified candidates looking for their next opportunity.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="space-y-4 text-left">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-2 h-2 bg-white rounded-full"></div>
|
||||||
|
<span>Free job posting</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-2 h-2 bg-white rounded-full"></div>
|
||||||
|
<span>Access to verified candidates</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-2 h-2 bg-white rounded-full"></div>
|
||||||
|
<span>Easy application management</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-2 h-2 bg-white rounded-full"></div>
|
||||||
|
<span>Professional dashboard</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right Panel - Form */}
|
||||||
|
<div className="flex-1 p-8 flex flex-col justify-center relative">
|
||||||
|
<div className="absolute top-4 right-4">
|
||||||
|
<LanguageSwitcher />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="w-full max-w-md mx-auto">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="mb-6">
|
||||||
|
<Link
|
||||||
|
href="/jobs"
|
||||||
|
className="inline-flex items-center gap-2 text-muted-foreground hover:text-foreground mb-4 transition-colors"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="w-4 h-4" />
|
||||||
|
Back to jobs
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<h2 className="text-2xl font-bold text-foreground mb-2">
|
||||||
|
Post a new job
|
||||||
|
</h2>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Fill in the details below to create your job listing
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Progress Indicator */}
|
||||||
|
<div className="mb-8">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<span className="text-sm font-medium">
|
||||||
|
Step {currentStep} of 2
|
||||||
|
</span>
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
{currentStep === 1 && "Job Details"}
|
||||||
|
{currentStep === 2 && "Salary & Company"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-full bg-muted rounded-full h-2">
|
||||||
|
<div
|
||||||
|
className="bg-primary h-2 rounded-full transition-all duration-300"
|
||||||
|
style={{ width: `${(currentStep / 2) * 100}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={onSubmit} className="space-y-6">
|
||||||
|
{/* Step 1: Job Details */}
|
||||||
|
{currentStep === 1 && (
|
||||||
|
<motion.div
|
||||||
|
key="step1"
|
||||||
|
variants={stepVariants}
|
||||||
|
initial="hidden"
|
||||||
|
animate="visible"
|
||||||
|
exit="exit"
|
||||||
|
className="space-y-4"
|
||||||
|
>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="title">
|
||||||
|
<Briefcase className="inline h-4 w-4 mr-1" />
|
||||||
|
Job Title *
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="title"
|
||||||
|
placeholder="e.g. Senior Software Engineer"
|
||||||
|
value={formData.title}
|
||||||
|
onChange={(e) => updateField("title", e.target.value)}
|
||||||
|
/>
|
||||||
|
{formData.title.length > 0 && formData.title.length < 5 && (
|
||||||
|
<span className="text-sm text-destructive">
|
||||||
|
Title must be at least 5 characters
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="description">Description *</Label>
|
||||||
|
<Textarea
|
||||||
|
id="description"
|
||||||
|
placeholder="Describe the role, responsibilities, and requirements..."
|
||||||
|
rows={5}
|
||||||
|
value={formData.description}
|
||||||
|
onChange={(e) => updateField("description", e.target.value)}
|
||||||
|
/>
|
||||||
|
{formData.description.length > 0 &&
|
||||||
|
formData.description.length < 20 && (
|
||||||
|
<span className="text-sm text-destructive">
|
||||||
|
Description must be at least 20 characters
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="location">
|
||||||
|
<MapPin className="inline h-4 w-4 mr-1" />
|
||||||
|
Location
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="location"
|
||||||
|
placeholder="e.g. São Paulo, SP or Remote"
|
||||||
|
value={formData.location}
|
||||||
|
onChange={(e) => updateField("location", e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>
|
||||||
|
<Clock className="inline h-4 w-4 mr-1" />
|
||||||
|
Contract Type
|
||||||
|
</Label>
|
||||||
|
<Select
|
||||||
|
value={formData.employmentType}
|
||||||
|
onValueChange={(v) => updateField("employmentType", v)}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select contract type" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="permanent">Permanent</SelectItem>
|
||||||
|
<SelectItem value="full-time">Full-time</SelectItem>
|
||||||
|
<SelectItem value="part-time">Part-time</SelectItem>
|
||||||
|
<SelectItem value="contract">Contract</SelectItem>
|
||||||
|
<SelectItem value="temporary">Temporary</SelectItem>
|
||||||
|
<SelectItem value="training">Training</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button type="button" onClick={nextStep} className="w-full">
|
||||||
|
Next
|
||||||
|
</Button>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Step 2: Salary & Company */}
|
||||||
|
{currentStep === 2 && (
|
||||||
|
<motion.div
|
||||||
|
key="step2"
|
||||||
|
variants={stepVariants}
|
||||||
|
initial="hidden"
|
||||||
|
animate="visible"
|
||||||
|
exit="exit"
|
||||||
|
className="space-y-4"
|
||||||
|
>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>
|
||||||
|
<DollarSign className="inline h-4 w-4 mr-1" />
|
||||||
|
Min Salary
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
placeholder="e.g. 5000"
|
||||||
|
value={formData.salaryMin}
|
||||||
|
onChange={(e) => updateField("salaryMin", e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Max Salary</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
placeholder="e.g. 10000"
|
||||||
|
value={formData.salaryMax}
|
||||||
|
onChange={(e) => updateField("salaryMax", e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Currency</Label>
|
||||||
|
<Select
|
||||||
|
value={formData.currency}
|
||||||
|
onValueChange={(v) => updateField("currency", v)}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="BRL">R$ (BRL)</SelectItem>
|
||||||
|
<SelectItem value="USD">$ (USD)</SelectItem>
|
||||||
|
<SelectItem value="EUR">€ (EUR)</SelectItem>
|
||||||
|
<SelectItem value="JPY">¥ (JPY)</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Period</Label>
|
||||||
|
<Select
|
||||||
|
value={formData.salaryType}
|
||||||
|
onValueChange={(v) => updateField("salaryType", v)}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="hourly">Per hour</SelectItem>
|
||||||
|
<SelectItem value="daily">Per day</SelectItem>
|
||||||
|
<SelectItem value="weekly">Per week</SelectItem>
|
||||||
|
<SelectItem value="monthly">Per month</SelectItem>
|
||||||
|
<SelectItem value="yearly">Per year</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>
|
||||||
|
<Clock className="inline h-4 w-4 mr-1" />
|
||||||
|
Working Hours
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
placeholder="e.g. 9:00 - 18:00, Mon-Fri"
|
||||||
|
value={formData.workingHours}
|
||||||
|
onChange={(e) => updateField("workingHours", e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>
|
||||||
|
<Building2 className="inline h-4 w-4 mr-1" />
|
||||||
|
Company *
|
||||||
|
</Label>
|
||||||
|
{loadingCompanies ? (
|
||||||
|
<div className="flex items-center gap-2 text-muted-foreground py-2">
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
Loading companies...
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Select
|
||||||
|
value={formData.companyId}
|
||||||
|
onValueChange={(v) => updateField("companyId", v)}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select a company" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{companies.length === 0 ? (
|
||||||
|
<SelectItem value="__none" disabled>
|
||||||
|
No companies available
|
||||||
|
</SelectItem>
|
||||||
|
) : (
|
||||||
|
companies.map((company) => (
|
||||||
|
<SelectItem key={company.id} value={company.id}>
|
||||||
|
{company.name}
|
||||||
|
</SelectItem>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={prevStep}
|
||||||
|
className="flex-1"
|
||||||
|
>
|
||||||
|
Back
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading || !canSubmit()}
|
||||||
|
className="flex-1"
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||||
|
Posting...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Briefcase className="h-4 w-4 mr-2" />
|
||||||
|
Post Job
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div className="mt-6 text-center">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Want to manage your jobs?{" "}
|
||||||
|
<Link
|
||||||
|
href="/login"
|
||||||
|
className="text-primary hover:underline font-medium"
|
||||||
|
>
|
||||||
|
Sign in
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue