- Added Next.js frontend for candidate interviews - Added Node.js backend with TypeScript and AI integration - Added ASP.NET Core chatbot service for specialized AI conversations - Added MySQL database with complete schema - Added Nginx reverse proxy configuration - Complete Docker Compose orchestration for all services - Environment configuration for production, development, and Cloudflare - Comprehensive documentation and setup instructions - Flattened nested folder structures for clean organization - Integrated chatbot service with fallback to direct AI calls
789 lines
37 KiB
TypeScript
789 lines
37 KiB
TypeScript
"use client";
|
|
|
|
import { useState } from 'react';
|
|
import { z } from 'zod';
|
|
import axios from 'axios';
|
|
|
|
// Form validation schema
|
|
const createJobSchema = z.object({
|
|
title: z.string().min(1, 'Job title is required').max(255, 'Title too long'),
|
|
description: z.string().min(1, 'Job description is required'),
|
|
requirements: z.string().min(1, 'Job requirements are required'),
|
|
skills_required: z.array(z.string()).min(1, 'At least one skill is required'),
|
|
location: z.string().optional(),
|
|
employment_type: z.enum(['full_time', 'part_time', 'contract', 'internship']).default('full_time'),
|
|
experience_level: z.enum(['entry', 'mid', 'senior', 'lead', 'executive']).default('mid'),
|
|
salary_min: z.number().min(0).optional(),
|
|
salary_max: z.number().min(0).optional(),
|
|
currency: z.string().length(3).default('USD'),
|
|
application_deadline: z.string().optional(),
|
|
icon: z.string().optional(),
|
|
});
|
|
|
|
type CreateJobFormData = z.infer<typeof createJobSchema>;
|
|
|
|
interface CreateJobModalProps {
|
|
isOpen: boolean;
|
|
onClose: () => void;
|
|
onSubmit: (jobData: CreateJobFormData) => void;
|
|
}
|
|
|
|
export default function CreateJobModal({ isOpen, onClose, onSubmit }: CreateJobModalProps) {
|
|
const [formData, setFormData] = useState<CreateJobFormData>({
|
|
title: '',
|
|
description: '',
|
|
requirements: '',
|
|
skills_required: [],
|
|
location: '',
|
|
employment_type: 'full_time',
|
|
experience_level: 'mid',
|
|
salary_min: undefined,
|
|
salary_max: undefined,
|
|
currency: 'USD',
|
|
application_deadline: '',
|
|
icon: 'briefcase',
|
|
});
|
|
|
|
const [errors, setErrors] = useState<Record<string, string>>({});
|
|
const [skillInput, setSkillInput] = useState('');
|
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
const [currentStep, setCurrentStep] = useState(1);
|
|
const [shakeAnimation, setShakeAnimation] = useState(false);
|
|
const totalSteps = 4;
|
|
|
|
// Available job icons
|
|
const jobIcons = [
|
|
{ id: 'briefcase', name: 'Briefcase', emoji: '💼' },
|
|
{ id: 'code', name: 'Code', emoji: '💻' },
|
|
{ id: 'chart', name: 'Analytics', emoji: '📊' },
|
|
{ id: 'design', name: 'Design', emoji: '🎨' },
|
|
{ id: 'marketing', name: 'Marketing', emoji: '📈' },
|
|
{ id: 'sales', name: 'Sales', emoji: '💼' },
|
|
{ id: 'support', name: 'Support', emoji: '🎧' },
|
|
{ id: 'engineering', name: 'Engineering', emoji: '⚙️' },
|
|
{ id: 'data', name: 'Data', emoji: '📊' },
|
|
{ id: 'security', name: 'Security', emoji: '🔒' },
|
|
{ id: 'mobile', name: 'Mobile', emoji: '📱' },
|
|
{ id: 'cloud', name: 'Cloud', emoji: '☁️' },
|
|
];
|
|
|
|
const handleInputChange = (field: keyof CreateJobFormData, value: any) => {
|
|
setFormData(prev => ({ ...prev, [field]: value }));
|
|
// Clear error when user starts typing
|
|
if (errors[field]) {
|
|
setErrors(prev => ({ ...prev, [field]: '' }));
|
|
}
|
|
// Clear shake animation when user starts typing
|
|
if (shakeAnimation) {
|
|
setShakeAnimation(false);
|
|
}
|
|
};
|
|
|
|
const handleAddSkill = () => {
|
|
if (skillInput.trim() && !formData.skills_required.includes(skillInput.trim())) {
|
|
setFormData(prev => ({
|
|
...prev,
|
|
skills_required: [...prev.skills_required, skillInput.trim()]
|
|
}));
|
|
setSkillInput('');
|
|
}
|
|
};
|
|
|
|
const handleRemoveSkill = (skillToRemove: string) => {
|
|
setFormData(prev => ({
|
|
...prev,
|
|
skills_required: prev.skills_required.filter(skill => skill !== skillToRemove)
|
|
}));
|
|
};
|
|
|
|
const validateCurrentStep = () => {
|
|
const newErrors: Record<string, string> = {};
|
|
|
|
if (currentStep === 1) {
|
|
// Validate Step 1: Basic Information
|
|
if (!formData.title.trim()) {
|
|
newErrors.title = 'Job title is required';
|
|
}
|
|
// Other fields in step 1 are optional
|
|
} else if (currentStep === 2) {
|
|
// Validate Step 2: Job Details
|
|
if (formData.skills_required.length === 0) {
|
|
newErrors.skills_required = 'At least one skill is required';
|
|
}
|
|
if (!formData.description.trim()) {
|
|
newErrors.description = 'Job description is required';
|
|
}
|
|
if (!formData.requirements.trim()) {
|
|
newErrors.requirements = 'Job requirements are required';
|
|
}
|
|
} else if (currentStep === 3) {
|
|
// Validate Step 3: Icon Selection
|
|
if (!formData.icon) {
|
|
newErrors.icon = 'Please select an icon for this job';
|
|
}
|
|
}
|
|
|
|
setErrors(newErrors);
|
|
return Object.keys(newErrors).length === 0;
|
|
};
|
|
|
|
const nextStep = () => {
|
|
if (validateCurrentStep() && currentStep < totalSteps) {
|
|
setCurrentStep(currentStep + 1);
|
|
} else {
|
|
// Trigger shake animation when validation fails
|
|
setShakeAnimation(true);
|
|
setTimeout(() => setShakeAnimation(false), 500);
|
|
}
|
|
};
|
|
|
|
const prevStep = () => {
|
|
if (currentStep > 1) {
|
|
setCurrentStep(currentStep - 1);
|
|
}
|
|
};
|
|
|
|
const handleSubmit = async (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
console.log('Form submitted, current step:', currentStep, 'total steps:', totalSteps);
|
|
|
|
// Only create job if we're on the final step (review)
|
|
if (currentStep === totalSteps) {
|
|
console.log('Creating job on final step');
|
|
await createJob();
|
|
} else {
|
|
console.log('Not on final step, just validating');
|
|
// Just validate and move to next step
|
|
if (validateCurrentStep()) {
|
|
nextStep();
|
|
}
|
|
}
|
|
};
|
|
|
|
const createJob = async () => {
|
|
setIsSubmitting(true);
|
|
|
|
try {
|
|
console.log('Creating job with form data:', formData);
|
|
const validatedData = createJobSchema.parse(formData);
|
|
console.log('Validated data:', validatedData);
|
|
|
|
// Call the API to create the job
|
|
const token = localStorage.getItem("token");
|
|
console.log('Sending request to backend...');
|
|
const response = await axios.post(`${process.env.NEXT_PUBLIC_API_URL}/rest/jobs`, validatedData, {
|
|
headers: {
|
|
Authorization: `Bearer ${token}`,
|
|
'Content-Type': 'application/json'
|
|
}
|
|
});
|
|
|
|
console.log('Job created successfully:', response.data);
|
|
|
|
// Call the parent onSubmit callback
|
|
onSubmit(validatedData);
|
|
|
|
// Reset form
|
|
setFormData({
|
|
title: '',
|
|
description: '',
|
|
requirements: '',
|
|
skills_required: [],
|
|
location: '',
|
|
employment_type: 'full_time',
|
|
experience_level: 'mid',
|
|
salary_min: undefined,
|
|
salary_max: undefined,
|
|
currency: 'USD',
|
|
application_deadline: '',
|
|
});
|
|
setErrors({});
|
|
setCurrentStep(1);
|
|
onClose();
|
|
} catch (error) {
|
|
if (error instanceof z.ZodError) {
|
|
const fieldErrors: Record<string, string> = {};
|
|
error.issues.forEach((err) => {
|
|
if (err.path[0]) {
|
|
fieldErrors[err.path[0] as string] = err.message;
|
|
}
|
|
});
|
|
setErrors(fieldErrors);
|
|
} else {
|
|
console.error('Error creating job:', error);
|
|
|
|
// Handle API errors
|
|
if ((error as any).response) {
|
|
// Server responded with error status
|
|
console.error('API Error Response:', (error as any).response.data);
|
|
console.error('API Error Status:', (error as any).response.status);
|
|
console.error('API Error Headers:', (error as any).response.headers);
|
|
|
|
// Show user-friendly error message
|
|
alert(`Failed to create job: ${(error as any).response.data?.message || (error as any).response.statusText || 'Unknown error'}`);
|
|
} else if ((error as any).request) {
|
|
// Request was made but no response received
|
|
console.error('API Error Request:', (error as any).request);
|
|
alert('Failed to create job: No response from server. Please check if the backend is running.');
|
|
} else {
|
|
// Something else happened
|
|
console.error('API Error:', (error as any).message);
|
|
alert(`Failed to create job: ${(error as any).message}`);
|
|
}
|
|
}
|
|
} finally {
|
|
setIsSubmitting(false);
|
|
}
|
|
};
|
|
|
|
if (!isOpen) return null;
|
|
|
|
return (
|
|
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
|
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-2xl w-full max-w-4xl max-h-[95vh] overflow-hidden">
|
|
{/* Header with Progress */}
|
|
<div className="bg-gradient-to-r from-blue-600 to-indigo-600 px-8 py-6 text-white">
|
|
<div className="flex justify-between items-center mb-4">
|
|
<div>
|
|
<h2 className="text-2xl font-bold">Create New Job</h2>
|
|
<p className="text-blue-100 mt-1">Step {currentStep} of {totalSteps}</p>
|
|
</div>
|
|
<button
|
|
onClick={onClose}
|
|
className="text-blue-200 hover:text-white transition-colors p-2 rounded-lg hover:bg-blue-700"
|
|
>
|
|
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
|
|
{/* Progress Bar */}
|
|
<div className="w-full bg-blue-500 bg-opacity-30 rounded-full h-2">
|
|
<div
|
|
className="bg-white h-2 rounded-full transition-all duration-300 ease-out"
|
|
style={{ width: `${(currentStep / totalSteps) * 100}%` }}
|
|
></div>
|
|
</div>
|
|
|
|
{/* Step Indicators */}
|
|
<div className="flex justify-between mt-4">
|
|
{[1, 2, 3, 4].map((step) => {
|
|
const hasErrors = (step === 1 && errors.title) ||
|
|
(step === 2 && (errors.skills_required || errors.description || errors.requirements)) ||
|
|
(step === 3 && errors.icon);
|
|
|
|
return (
|
|
//step 3 shall be called "Icon" and step 4 shall be called "Review"
|
|
<div key={step} className="flex items-center">
|
|
<div className={`w-8 h-8 rounded-full flex items-center justify-center text-sm font-medium transition-all duration-200 ${
|
|
step <= currentStep
|
|
? hasErrors
|
|
? 'bg-red-500 text-white animate-pulse'
|
|
: 'bg-white text-blue-600'
|
|
: 'bg-blue-500 bg-opacity-30 text-blue-200'
|
|
}`}>
|
|
{hasErrors ? (
|
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
</svg>
|
|
) : (
|
|
step
|
|
)}
|
|
</div>
|
|
<span className={`ml-2 text-sm transition-colors ${
|
|
hasErrors ? 'text-red-200' : 'text-blue-100'
|
|
}`}>
|
|
{step === 1 ? 'Basic Info' : step === 2 ? 'Details' : step === 3 ? 'Icon' : 'Review'}
|
|
</span>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Form Content */}
|
|
<div className={`p-8 overflow-y-auto max-h-[calc(95vh-200px)] transition-transform duration-500 ${
|
|
shakeAnimation ? 'animate-pulse' : ''
|
|
}`}>
|
|
{/* Validation Error Summary */}
|
|
{shakeAnimation && Object.keys(errors).length > 0 && (
|
|
<div className="mb-6 p-4 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
|
|
<div className="flex items-start space-x-3">
|
|
<svg className="w-5 h-5 text-red-500 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
</svg>
|
|
<div>
|
|
<h4 className="text-sm font-medium text-red-800 dark:text-red-200">Please complete the required fields:</h4>
|
|
<ul className="mt-2 text-sm text-red-700 dark:text-red-300 space-y-1">
|
|
{errors.title && <li>• Job title is required</li>}
|
|
{errors.skills_required && <li>• At least one skill is required</li>}
|
|
{errors.description && <li>• Job description is required</li>}
|
|
{errors.requirements && <li>• Job requirements are required</li>}
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
<form onSubmit={handleSubmit} id="job-form">
|
|
{/* Step 1: Basic Information */}
|
|
{currentStep === 1 && (
|
|
<div className="space-y-6">
|
|
<div className="text-center mb-8">
|
|
<h3 className="text-xl font-semibold text-gray-900 dark:text-white mb-2">Basic Information</h3>
|
|
<p className="text-gray-600 dark:text-gray-400">Let's start with the essential details about this position</p>
|
|
</div>
|
|
|
|
<div className="space-y-6">
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
|
Job Title *
|
|
</label>
|
|
<input
|
|
type="text"
|
|
value={formData.title}
|
|
onChange={(e) => handleInputChange('title', e.target.value)}
|
|
className={`w-full px-4 py-3 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white transition-colors ${
|
|
errors.title ? 'border-red-500 bg-red-50 dark:bg-red-900/20 ring-2 ring-red-200 dark:ring-red-800' : 'border-gray-300'
|
|
}`}
|
|
placeholder="e.g. Senior Frontend Developer"
|
|
/>
|
|
{errors.title && <p className="text-red-500 text-sm mt-1">{errors.title}</p>}
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
|
Employment Type
|
|
</label>
|
|
<select
|
|
value={formData.employment_type}
|
|
onChange={(e) => handleInputChange('employment_type', e.target.value)}
|
|
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white"
|
|
>
|
|
<option value="full_time">Full Time</option>
|
|
<option value="part_time">Part Time</option>
|
|
<option value="contract">Contract</option>
|
|
<option value="internship">Internship</option>
|
|
</select>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
|
Experience Level
|
|
</label>
|
|
<select
|
|
value={formData.experience_level}
|
|
onChange={(e) => handleInputChange('experience_level', e.target.value)}
|
|
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white"
|
|
>
|
|
<option value="entry">Entry Level</option>
|
|
<option value="mid">Mid Level</option>
|
|
<option value="senior">Senior Level</option>
|
|
<option value="lead">Lead</option>
|
|
<option value="executive">Executive</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
|
Location
|
|
</label>
|
|
<input
|
|
type="text"
|
|
value={formData.location}
|
|
onChange={(e) => handleInputChange('location', e.target.value)}
|
|
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white"
|
|
placeholder="e.g. New York, NY or Remote"
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
|
Application Deadline
|
|
</label>
|
|
<input
|
|
type="date"
|
|
value={formData.application_deadline}
|
|
onChange={(e) => handleInputChange('application_deadline', e.target.value)}
|
|
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Step 2: Job Details */}
|
|
{currentStep === 2 && (
|
|
<div className="space-y-6">
|
|
<div className="text-center mb-8">
|
|
<h3 className="text-xl font-semibold text-gray-900 dark:text-white mb-2">Job Details</h3>
|
|
<p className="text-gray-600 dark:text-gray-400">Add comprehensive details about the role and compensation</p>
|
|
</div>
|
|
|
|
<div className="space-y-6">
|
|
{/* Salary Information */}
|
|
<div className="bg-gray-50 dark:bg-gray-700 p-6 rounded-lg">
|
|
<h4 className="text-lg font-medium text-gray-900 dark:text-white mb-4">Compensation</h4>
|
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
|
Min Salary
|
|
</label>
|
|
<input
|
|
type="number"
|
|
value={formData.salary_min || ''}
|
|
onChange={(e) => handleInputChange('salary_min', e.target.value ? Number(e.target.value) : undefined)}
|
|
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-600 dark:border-gray-500 dark:text-white"
|
|
placeholder="50000"
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
|
Max Salary
|
|
</label>
|
|
<input
|
|
type="number"
|
|
value={formData.salary_max || ''}
|
|
onChange={(e) => handleInputChange('salary_max', e.target.value ? Number(e.target.value) : undefined)}
|
|
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-600 dark:border-gray-500 dark:text-white"
|
|
placeholder="80000"
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
|
Currency
|
|
</label>
|
|
<select
|
|
value={formData.currency}
|
|
onChange={(e) => handleInputChange('currency', e.target.value)}
|
|
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-600 dark:border-gray-500 dark:text-white"
|
|
>
|
|
<option value="USD">USD</option>
|
|
<option value="EUR">EUR</option>
|
|
<option value="GBP">GBP</option>
|
|
<option value="CAD">CAD</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Skills Required */}
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
|
Required Skills *
|
|
</label>
|
|
<div className={`flex gap-2 mb-3 p-3 rounded-lg transition-colors ${
|
|
errors.skills_required ? 'bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800' : ''
|
|
}`}>
|
|
<input
|
|
type="text"
|
|
value={skillInput}
|
|
onChange={(e) => setSkillInput(e.target.value)}
|
|
onKeyPress={(e) => e.key === 'Enter' && (e.preventDefault(), handleAddSkill())}
|
|
className={`flex-1 px-4 py-3 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white transition-colors ${
|
|
errors.skills_required ? 'border-red-500 ring-2 ring-red-200 dark:ring-red-800' : 'border-gray-300'
|
|
}`}
|
|
placeholder="Add a skill and press Enter"
|
|
/>
|
|
<button
|
|
type="button"
|
|
onClick={handleAddSkill}
|
|
className="px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 transition-colors"
|
|
>
|
|
Add
|
|
</button>
|
|
</div>
|
|
{errors.skills_required && (
|
|
<div className="flex items-center space-x-2 text-red-500 text-sm mb-2">
|
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
</svg>
|
|
<span>{errors.skills_required}</span>
|
|
</div>
|
|
)}
|
|
<div className="flex flex-wrap gap-2">
|
|
{formData.skills_required.map((skill, index) => (
|
|
<span
|
|
key={index}
|
|
className="inline-flex items-center px-3 py-1 rounded-full text-sm bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-200"
|
|
>
|
|
{skill}
|
|
<button
|
|
type="button"
|
|
onClick={() => handleRemoveSkill(skill)}
|
|
className="ml-2 text-blue-600 dark:text-blue-300 hover:text-blue-800 dark:hover:text-blue-100"
|
|
>
|
|
×
|
|
</button>
|
|
</span>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Job Description */}
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
|
Job Description *
|
|
</label>
|
|
<textarea
|
|
value={formData.description}
|
|
onChange={(e) => handleInputChange('description', e.target.value)}
|
|
rows={5}
|
|
className={`w-full px-4 py-3 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white transition-colors ${
|
|
errors.description ? 'border-red-500 bg-red-50 dark:bg-red-900/20 ring-2 ring-red-200 dark:ring-red-800' : 'border-gray-300'
|
|
}`}
|
|
placeholder="Describe the role, responsibilities, and what makes this opportunity exciting..."
|
|
/>
|
|
{errors.description && (
|
|
<div className="flex items-center space-x-2 text-red-500 text-sm mt-1">
|
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
</svg>
|
|
<span>{errors.description}</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Job Requirements */}
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
|
Job Requirements *
|
|
</label>
|
|
<textarea
|
|
value={formData.requirements}
|
|
onChange={(e) => handleInputChange('requirements', e.target.value)}
|
|
rows={5}
|
|
className={`w-full px-4 py-3 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white transition-colors ${
|
|
errors.requirements ? 'border-red-500 bg-red-50 dark:bg-red-900/20 ring-2 ring-red-200 dark:ring-red-800' : 'border-gray-300'
|
|
}`}
|
|
placeholder="List the specific requirements, qualifications, and experience needed..."
|
|
/>
|
|
{errors.requirements && (
|
|
<div className="flex items-center space-x-2 text-red-500 text-sm mt-1">
|
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
</svg>
|
|
<span>{errors.requirements}</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Step 3: Icon Selection */}
|
|
{currentStep === 3 && (
|
|
<div className="space-y-6">
|
|
<div className="text-center mb-8">
|
|
<h3 className="text-xl font-semibold text-gray-900 dark:text-white mb-2">Choose an Icon</h3>
|
|
<p className="text-gray-600 dark:text-gray-400">Select an icon that best represents this job position</p>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-4">
|
|
{jobIcons.map((icon) => (
|
|
<button
|
|
key={icon.id}
|
|
type="button"
|
|
onClick={() => handleInputChange('icon', icon.id)}
|
|
className={`p-4 rounded-lg border-2 transition-all duration-200 hover:scale-105 ${
|
|
formData.icon === icon.id
|
|
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20 ring-2 ring-blue-200 dark:ring-blue-800'
|
|
: 'border-gray-300 dark:border-gray-600 hover:border-gray-400 dark:hover:border-gray-500'
|
|
} ${errors.icon ? 'border-red-500 bg-red-50 dark:bg-red-900/20 ring-2 ring-red-200 dark:ring-red-800' : ''}`}
|
|
>
|
|
<div className="text-3xl mb-2">{icon.emoji}</div>
|
|
<div className="text-xs text-gray-600 dark:text-gray-400 text-center">{icon.name}</div>
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{errors.icon && (
|
|
<div className="text-red-500 text-sm text-center">{errors.icon}</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Step 4: Review */}
|
|
{currentStep === 4 && (
|
|
<div className="space-y-6">
|
|
<div className="text-center mb-8">
|
|
<h3 className="text-xl font-semibold text-gray-900 dark:text-white mb-2">Review & Create</h3>
|
|
<p className="text-gray-600 dark:text-gray-400">Review your job posting before publishing</p>
|
|
</div>
|
|
|
|
<div className="bg-gray-50 dark:bg-gray-700 p-6 rounded-lg space-y-4">
|
|
{/* Job Preview Card */}
|
|
<div className="bg-white dark:bg-gray-800 p-4 rounded-lg border border-gray-200 dark:border-gray-600">
|
|
<div className="flex items-start space-x-4">
|
|
<div className="text-4xl">
|
|
{jobIcons.find(icon => icon.id === formData.icon)?.emoji || '💼'}
|
|
</div>
|
|
<div className="flex-1">
|
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">{formData.title}</h3>
|
|
<p className="text-gray-600 dark:text-gray-400 text-sm mt-1 line-clamp-2">
|
|
{formData.description?.substring(0, 100)}...
|
|
</p>
|
|
<div className="flex flex-wrap gap-2 mt-2">
|
|
<span className="px-2 py-1 bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-200 text-xs rounded">
|
|
{formData.employment_type?.replace('_', ' ')}
|
|
</span>
|
|
<span className="px-2 py-1 bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200 text-xs rounded">
|
|
{formData.experience_level?.replace('_', ' ')}
|
|
</span>
|
|
{formData.location && (
|
|
<span className="px-2 py-1 bg-gray-100 dark:bg-gray-700 text-gray-800 dark:text-gray-200 text-xs rounded">
|
|
{formData.location}
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
<div>
|
|
<h4 className="font-medium text-gray-900 dark:text-white">Job Title</h4>
|
|
<p className="text-gray-600 dark:text-gray-400">{formData.title || 'Not specified'}</p>
|
|
</div>
|
|
<div>
|
|
<h4 className="font-medium text-gray-900 dark:text-white">Employment Type</h4>
|
|
<p className="text-gray-600 dark:text-gray-400 capitalize">{formData.employment_type?.replace('_', ' ')}</p>
|
|
</div>
|
|
<div>
|
|
<h4 className="font-medium text-gray-900 dark:text-white">Experience Level</h4>
|
|
<p className="text-gray-600 dark:text-gray-400 capitalize">{formData.experience_level?.replace('_', ' ')}</p>
|
|
</div>
|
|
<div>
|
|
<h4 className="font-medium text-gray-900 dark:text-white">Location</h4>
|
|
<p className="text-gray-600 dark:text-gray-400">{formData.location || 'Not specified'}</p>
|
|
</div>
|
|
<div>
|
|
<h4 className="font-medium text-gray-900 dark:text-white">Salary Range</h4>
|
|
<p className="text-gray-600 dark:text-gray-400">
|
|
{formData.salary_min && formData.salary_max
|
|
? `${formData.currency} ${formData.salary_min.toLocaleString()} - ${formData.salary_max.toLocaleString()}`
|
|
: formData.salary_min
|
|
? `${formData.currency} ${formData.salary_min.toLocaleString()}+`
|
|
: 'Not specified'
|
|
}
|
|
</p>
|
|
</div>
|
|
<div>
|
|
<h4 className="font-medium text-gray-900 dark:text-white">Application Deadline</h4>
|
|
<p className="text-gray-600 dark:text-gray-400">
|
|
{formData.application_deadline
|
|
? new Date(formData.application_deadline).toLocaleDateString()
|
|
: 'No deadline set'
|
|
}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<h4 className="font-medium text-gray-900 dark:text-white mb-2">Required Skills</h4>
|
|
<div className="flex flex-wrap gap-2">
|
|
{formData.skills_required.length > 0 ? (
|
|
formData.skills_required.map((skill, index) => (
|
|
<span key={index} className="px-3 py-1 bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-200 rounded-full text-sm">
|
|
{skill}
|
|
</span>
|
|
))
|
|
) : (
|
|
<p className="text-gray-500 dark:text-gray-400">No skills specified</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<h4 className="font-medium text-gray-900 dark:text-white mb-2">Description</h4>
|
|
<p className="text-gray-600 dark:text-gray-400 whitespace-pre-wrap">{formData.description || 'No description provided'}</p>
|
|
</div>
|
|
|
|
<div>
|
|
<h4 className="font-medium text-gray-900 dark:text-white mb-2">Requirements</h4>
|
|
<p className="text-gray-600 dark:text-gray-400 whitespace-pre-wrap">{formData.requirements || 'No requirements specified'}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Navigation Buttons */}
|
|
<div className="flex justify-between pt-8 border-t border-gray-200 dark:border-gray-700">
|
|
<div>
|
|
{currentStep > 1 && (
|
|
<button
|
|
type="button"
|
|
onClick={prevStep}
|
|
className="px-6 py-3 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-gray-500 transition-colors"
|
|
>
|
|
Previous
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
<div className="flex space-x-4">
|
|
<button
|
|
type="button"
|
|
onClick={onClose}
|
|
className="px-6 py-3 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-gray-500 transition-colors"
|
|
>
|
|
Cancel
|
|
</button>
|
|
|
|
{currentStep < totalSteps ? (
|
|
<button
|
|
type="button"
|
|
onClick={(e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
if (validateCurrentStep()) {
|
|
nextStep();
|
|
} else {
|
|
setShakeAnimation(true);
|
|
setTimeout(() => setShakeAnimation(false), 500);
|
|
}
|
|
}}
|
|
className={`px-6 py-3 text-white rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 transition-all duration-200 ${
|
|
shakeAnimation
|
|
? 'bg-red-600 hover:bg-red-700 ring-2 ring-red-300 animate-pulse'
|
|
: 'bg-blue-600 hover:bg-blue-700'
|
|
}`}
|
|
>
|
|
{shakeAnimation ? 'Please fill required fields' : 'Next'}
|
|
</button>
|
|
) : (
|
|
<button
|
|
type="submit"
|
|
disabled={isSubmitting}
|
|
className="px-6 py-3 bg-green-600 text-white rounded-lg hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-green-500 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center space-x-2"
|
|
>
|
|
{isSubmitting ? (
|
|
<>
|
|
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin"></div>
|
|
<span>Creating...</span>
|
|
</>
|
|
) : (
|
|
<>
|
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
|
</svg>
|
|
<span>Create Job</span>
|
|
</>
|
|
)}
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|