displaying vacancy completed

This commit is contained in:
libertyoms
2025-04-27 09:58:38 +02:00
parent 1be00d7a42
commit d0b0d10124
7 changed files with 456 additions and 254 deletions

View File

@ -1,46 +1,81 @@
// src/components/VacancyApplicationForm.tsx (Example structure and styling)
"use client"; // This likely needs to be a client component for form handling
"use client";
import React, { useState, useCallback, ChangeEvent, DragEvent } from "react";
import { FaUpload, FaFileAlt, FaTimes } from "react-icons/fa"; // Import icons
import { FaUpload, FaFileAlt, FaTimes } from "react-icons/fa";
import { useForm, SubmitHandler } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import Button from "./ui/Button";
const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB
const ACCEPTED_FILE_TYPES = [
"application/pdf",
"application/msword",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"text/plain",
];
const applicationSchema = z.object({
name: z.string().min(2, "Full name must be at least 2 characters"),
email: z.string().email("Invalid email address"),
phone: z.string().optional(),
linkedin: z.string().url("Invalid URL").optional().or(z.literal("")),
coverLetter: z.string().optional(),
resume: z
.custom<File | null>((val) => val instanceof File, "Resume/CV is required")
.refine(
(file) => file && file.size <= MAX_FILE_SIZE,
`Max file size is 5MB.`
)
.refine(
(file) => file && ACCEPTED_FILE_TYPES.includes(file.type),
"Unsupported file format. Please upload PDF, DOC, DOCX, or TXT."
),
});
type ApplicationFormData = z.infer<typeof applicationSchema>;
interface VacancyApplicationFormProps {
vacancyId: string;
vacancyTitle: string;
// onFormClose?: () => void; // Optional: If you add a close button
onClose: () => void;
}
export default function VacancyApplicationForm({
vacancyId,
vacancyTitle,
onClose,
}: VacancyApplicationFormProps) {
const [formData, setFormData] = useState({
name: "",
email: "",
phone: "",
linkedin: "",
coverLetter: "",
// Add other fields as needed
const {
register,
handleSubmit,
setValue,
watch,
formState: { errors, isSubmitting },
reset,
} = useForm<ApplicationFormData>({
resolver: zodResolver(applicationSchema),
mode: "onChange",
defaultValues: {
name: "",
email: "",
phone: "",
linkedin: "",
coverLetter: "",
resume: null,
},
});
const [resumeFile, setResumeFile] = useState<File | null>(null);
const resumeFile = watch("resume");
const [isDragging, setIsDragging] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const [submitStatus, setSubmitStatus] = useState<
"idle" | "success" | "error"
>("idle");
const handleInputChange = (
e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>
) => {
const { name, value } = e.target;
setFormData((prev) => ({ ...prev, [name]: value }));
};
// --- File Input Handling ---
const handleFileChange = (e: ChangeEvent<HTMLInputElement>) => {
if (e.target.files && e.target.files.length > 0) {
setResumeFile(e.target.files[0]);
e.target.value = ""; // Reset input value for potential re-upload
setValue("resume", e.target.files[0], { shouldValidate: true });
e.target.value = "";
}
};
@ -58,161 +93,190 @@ export default function VacancyApplicationForm({
const handleDragOver = useCallback(
(e: DragEvent<HTMLDivElement>) => {
e.preventDefault(); // Necessary to allow drop
e.preventDefault();
e.stopPropagation();
if (!isDragging) setIsDragging(true); // Ensure state is true while over
if (!isDragging) setIsDragging(true);
},
[isDragging]
);
const handleDrop = useCallback((e: DragEvent<HTMLDivElement>) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
if (e.dataTransfer.files && e.dataTransfer.files.length > 0) {
setResumeFile(e.dataTransfer.files[0]);
e.dataTransfer.clearData();
}
}, []);
const handleDrop = useCallback(
(e: DragEvent<HTMLDivElement>) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
if (e.dataTransfer.files && e.dataTransfer.files.length > 0) {
setValue("resume", e.dataTransfer.files[0], { shouldValidate: true });
e.dataTransfer.clearData();
}
},
[setValue]
);
const removeFile = () => {
setResumeFile(null);
setValue("resume", null, { shouldValidate: true });
};
// --- End File Input Handling ---
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
setIsSubmitting(true);
const onSubmit: SubmitHandler<ApplicationFormData> = async (data) => {
setSubmitStatus("idle");
const data = new FormData();
data.append("vacancyId", vacancyId);
data.append("vacancyTitle", vacancyTitle);
data.append("name", formData.name);
data.append("email", formData.email);
data.append("phone", formData.phone);
data.append("linkedin", formData.linkedin);
data.append("coverLetter", formData.coverLetter);
if (resumeFile) {
data.append("resume", resumeFile, resumeFile.name);
}
const formData = new FormData();
formData.append("vacancyId", vacancyId);
formData.append("vacancyTitle", vacancyTitle);
formData.append("name", data.name);
formData.append("email", data.email);
if (data.phone) formData.append("phone", data.phone);
if (data.linkedin) formData.append("linkedin", data.linkedin);
if (data.coverLetter) formData.append("coverLetter", data.coverLetter);
if (data.resume) formData.append("resume", data.resume, data.resume.name);
try {
// Replace with your actual API endpoint for form submission
const response = await fetch("/api/apply", {
method: "POST",
body: data, // FormData handles multipart/form-data automatically
body: formData,
});
if (!response.ok) {
throw new Error("Submission failed");
}
if (!response.ok)
throw new Error(`Submission failed: ${response.statusText}`);
setSubmitStatus("success");
// Optionally reset form:
// setFormData({ name: '', email: '', ... });
// setResumeFile(null);
// Optionally close form: onFormClose?.();
reset();
setTimeout(() => {
onClose();
setSubmitStatus("idle");
}, 1500);
} catch (error) {
console.error("Application submission error:", error);
console.error(error);
setSubmitStatus("error");
} finally {
setIsSubmitting(false);
}
};
// --- Base Input Styling ---
const inputBaseStyle =
"block w-full px-4 py-2 mt-1 text-gray-700 bg-white border border-gray-300 rounded-md focus:border-gold-500 focus:ring focus:ring-gold-500 focus:ring-opacity-50 dark:bg-gray-800 dark:text-gray-300 dark:border-gray-600 dark:focus:border-gold-500 font-poppins";
const errorStyle = "border-red-500 focus:border-red-500 focus:ring-red-500";
const errorMessageStyle = "text-red-600 text-xs mt-1";
return (
<form onSubmit={handleSubmit} className="space-y-6 font-poppins">
{/* Example Fields */}
<div>
<label htmlFor="name" className="text-gray-700 dark:text-gray-200">
Full Name *
</label>
<input
id="name"
name="name"
type="text"
required
className={inputBaseStyle}
value={formData.name}
onChange={handleInputChange}
/>
</div>
<div>
<label htmlFor="email" className="text-gray-700 dark:text-gray-200">
Email Address *
</label>
<input
id="email"
name="email"
type="email"
required
className={inputBaseStyle}
value={formData.email}
onChange={handleInputChange}
/>
</div>
<div>
<label htmlFor="phone" className="text-gray-700 dark:text-gray-200">
Phone Number
</label>
<input
id="phone"
name="phone"
type="tel"
className={inputBaseStyle}
value={formData.phone}
onChange={handleInputChange}
/>
</div>
<div>
<label htmlFor="linkedin" className="text-gray-700 dark:text-gray-200">
LinkedIn Profile URL
</label>
<input
id="linkedin"
name="linkedin"
type="url"
className={inputBaseStyle}
value={formData.linkedin}
onChange={handleInputChange}
placeholder="https://linkedin.com/in/yourprofile"
/>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6 font-poppins">
{/* Row 1: Name & Email */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Name Field */}
<div>
<label htmlFor="name" className="text-gray-700 dark:text-gray-200">
Full Name *
</label>
<input
id="name"
type="text"
{...register("name")}
className={`${inputBaseStyle} ${errors.name ? errorStyle : ""}`}
aria-invalid={errors.name ? "true" : "false"}
/>
{errors.name && (
<p role="alert" className={errorMessageStyle}>
{errors.name.message}
</p>
)}
</div>
{/* Email Field */}
<div>
<label htmlFor="email" className="text-gray-700 dark:text-gray-200">
Email Address *
</label>
<input
id="email"
type="email"
{...register("email")}
className={`${inputBaseStyle} ${errors.email ? errorStyle : ""}`}
aria-invalid={errors.email ? "true" : "false"}
/>
{errors.email && (
<p role="alert" className={errorMessageStyle}>
{errors.email.message}
</p>
)}
</div>
</div>
{/* --- Styled File Upload Area --- */}
{/* Row 2: Phone & LinkedIn */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Phone Field */}
<div>
<label htmlFor="phone" className="text-gray-700 dark:text-gray-200">
Phone Number
</label>
<input
id="phone"
type="tel"
{...register("phone")}
className={`${inputBaseStyle} ${errors.phone ? errorStyle : ""}`}
aria-invalid={errors.phone ? "true" : "false"}
/>
{errors.phone && (
<p role="alert" className={errorMessageStyle}>
{errors.phone.message}
</p>
)}
</div>
{/* LinkedIn Field */}
<div>
<label
htmlFor="linkedin"
className="text-gray-700 dark:text-gray-200"
>
LinkedIn Profile URL
</label>
<input
id="linkedin"
type="url"
{...register("linkedin")}
className={`${inputBaseStyle} ${errors.linkedin ? errorStyle : ""}`}
placeholder="https://linkedin.com/in/yourprofile"
aria-invalid={errors.linkedin ? "true" : "false"}
/>
{errors.linkedin && (
<p role="alert" className={errorMessageStyle}>
{errors.linkedin.message}
</p>
)}
</div>
</div>
{/* Resume/CV Upload */}
<div>
<label className="text-gray-700 dark:text-gray-200 block mb-2">
Resume/CV *
</label>
<input
id="resume-upload-hidden"
type="file"
onChange={handleFileChange}
accept=".pdf,.doc,.docx,.txt"
className="hidden"
aria-hidden="true"
/>
<div
className={`relative flex flex-col items-center justify-center w-full p-6 transition-colors duration-300 ease-in-out border-2 border-dashed rounded-lg cursor-pointer hover:border-gold-500/80 ${
isDragging
? "border-gold-500 bg-gold-50"
: errors.resume
? "border-red-500"
: "border-gray-300 hover:bg-gray-50"
}`}
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
onDragOver={handleDragOver}
onDrop={handleDrop}
onClick={() => document.getElementById("resume-upload")?.click()} // Trigger hidden input
onClick={() =>
document.getElementById("resume-upload-hidden")?.click()
}
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ")
document.getElementById("resume-upload-hidden")?.click();
}}
aria-label="Upload Resume/CV"
>
{/* Hidden Actual Input */}
<input
id="resume-upload"
name="resume"
type="file"
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
onChange={handleFileChange}
accept=".pdf,.doc,.docx,.txt" // Specify accepted file types
aria-hidden="true" // Hide from accessibility tree as visual element handles interaction
/>
{/* Visual Cue */}
<FaUpload
className={`w-8 h-8 mb-3 ${
isDragging ? "text-gold-600" : "text-gray-400"
@ -229,10 +293,15 @@ export default function VacancyApplicationForm({
<p className="text-xs text-gray-500 text-center">
or click to browse
</p>
<p className="mt-2 text-xs text-gray-500">(PDF, DOC, DOCX, TXT)</p>
<p className="mt-2 text-xs text-gray-500">
(PDF, DOC, DOCX, TXT - Max 5MB)
</p>
</div>
{/* Display Selected File */}
{errors.resume && (
<p role="alert" className={errorMessageStyle}>
{(errors.resume as unknown as { message: string }).message}
</p>
)}
{resumeFile && (
<div className="mt-3 flex items-center justify-between px-3 py-2 text-sm text-gray-700 bg-gray-100 border border-gray-200 rounded-md">
<div className="flex items-center gap-2">
@ -255,8 +324,8 @@ export default function VacancyApplicationForm({
</div>
)}
</div>
{/* --- End File Upload Area --- */}
{/* Cover Letter Field */}
<div>
<label
htmlFor="coverLetter"
@ -266,34 +335,37 @@ export default function VacancyApplicationForm({
</label>
<textarea
id="coverLetter"
name="coverLetter"
rows={4}
className={`${inputBaseStyle} resize-vertical`}
value={formData.coverLetter}
onChange={handleInputChange}
{...register("coverLetter")} // Register with RHF
className={`${inputBaseStyle} resize-vertical ${
errors.coverLetter ? errorStyle : ""
}`}
aria-invalid={errors.coverLetter ? "true" : "false"}
></textarea>
{errors.coverLetter && (
<p role="alert" className={errorMessageStyle}>
{errors.coverLetter.message}
</p>
)}
</div>
{/* Submit Button & Status */}
<div className="flex flex-col items-center space-y-3 pt-4">
<button
<Button
type="submit"
disabled={
isSubmitting || !formData.name || !formData.email || !resumeFile
} // Basic validation example
className="w-full md:w-auto px-8 py-3 font-bold text-white bg-gray-800 rounded-md hover:bg-gray-900 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-gray-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors duration-300"
disabled={isSubmitting || submitStatus === "success"} // Disable while submitting or on success
>
{isSubmitting ? "Submitting..." : "Submit Application"}
</button>
</Button>
{submitStatus === "success" && (
<p className="text-sm text-green-600">
Application submitted successfully!
Application submitted successfully! Closing form...
</p>
)}
{submitStatus === "error" && (
<p className="text-sm text-red-600">
Submission failed. Please try again.
Submission failed. Please check your details and try again.
</p>
)}
</div>