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