mirror of
https://github.com/OwethuManagedServices/oms-website-nextjs.git
synced 2025-12-17 18:58:10 +00:00
375 lines
12 KiB
TypeScript
375 lines
12 KiB
TypeScript
"use client";
|
|
|
|
import React, { useState, useCallback, ChangeEvent, DragEvent } from "react";
|
|
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;
|
|
onClose: () => void;
|
|
}
|
|
|
|
export default function VacancyApplicationForm({
|
|
vacancyId,
|
|
vacancyTitle,
|
|
onClose,
|
|
}: VacancyApplicationFormProps) {
|
|
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 = watch("resume");
|
|
const [isDragging, setIsDragging] = useState(false);
|
|
const [submitStatus, setSubmitStatus] = useState<
|
|
"idle" | "success" | "error"
|
|
>("idle");
|
|
|
|
const handleFileChange = (e: ChangeEvent<HTMLInputElement>) => {
|
|
if (e.target.files && e.target.files.length > 0) {
|
|
setValue("resume", e.target.files[0], { shouldValidate: true });
|
|
e.target.value = "";
|
|
}
|
|
};
|
|
|
|
const handleDragEnter = useCallback((e: DragEvent<HTMLDivElement>) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
setIsDragging(true);
|
|
}, []);
|
|
|
|
const handleDragLeave = useCallback((e: DragEvent<HTMLDivElement>) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
setIsDragging(false);
|
|
}, []);
|
|
|
|
const handleDragOver = useCallback(
|
|
(e: DragEvent<HTMLDivElement>) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
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) {
|
|
setValue("resume", e.dataTransfer.files[0], { shouldValidate: true });
|
|
e.dataTransfer.clearData();
|
|
}
|
|
},
|
|
[setValue]
|
|
);
|
|
|
|
const removeFile = () => {
|
|
setValue("resume", null, { shouldValidate: true });
|
|
};
|
|
|
|
const onSubmit: SubmitHandler<ApplicationFormData> = async (data) => {
|
|
setSubmitStatus("idle");
|
|
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 {
|
|
const response = await fetch("/api/apply", {
|
|
method: "POST",
|
|
body: formData,
|
|
});
|
|
if (!response.ok)
|
|
throw new Error(`Submission failed: ${response.statusText}`);
|
|
setSubmitStatus("success");
|
|
reset();
|
|
setTimeout(() => {
|
|
onClose();
|
|
setSubmitStatus("idle");
|
|
}, 1500);
|
|
} catch (error) {
|
|
console.error(error);
|
|
setSubmitStatus("error");
|
|
}
|
|
};
|
|
|
|
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(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>
|
|
|
|
{/* 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-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"
|
|
>
|
|
<FaUpload
|
|
className={`w-8 h-8 mb-3 ${
|
|
isDragging ? "text-gold-600" : "text-gray-400"
|
|
}`}
|
|
style={{ color: isDragging ? "#c8a93d" : undefined }}
|
|
/>
|
|
<p
|
|
className={`text-sm font-semibold text-center ${
|
|
isDragging ? "text-gold-700" : "text-gray-600"
|
|
}`}
|
|
>
|
|
{isDragging ? "Drop file here" : "Drag & drop your file here"}
|
|
</p>
|
|
<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 - Max 5MB)
|
|
</p>
|
|
</div>
|
|
{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">
|
|
<FaFileAlt className="w-4 h-4 text-gray-500" />
|
|
<span className="font-medium truncate max-w-[200px] sm:max-w-xs">
|
|
{resumeFile.name}
|
|
</span>
|
|
<span className="text-xs text-gray-500">
|
|
({(resumeFile.size / 1024).toFixed(1)} KB)
|
|
</span>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={removeFile}
|
|
className="text-red-500 hover:text-red-700 focus:outline-none"
|
|
aria-label="Remove file"
|
|
>
|
|
<FaTimes className="w-4 h-4" />
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Cover Letter Field */}
|
|
<div>
|
|
<label
|
|
htmlFor="coverLetter"
|
|
className="text-gray-700 dark:text-gray-200"
|
|
>
|
|
Cover Letter (Optional)
|
|
</label>
|
|
<textarea
|
|
id="coverLetter"
|
|
rows={4}
|
|
{...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
|
|
type="submit"
|
|
disabled={isSubmitting || submitStatus === "success"} // Disable while submitting or on success
|
|
>
|
|
{isSubmitting ? "Submitting..." : "Submit Application"}
|
|
</Button>
|
|
|
|
{submitStatus === "success" && (
|
|
<p className="text-sm text-green-600">
|
|
Application submitted successfully! Closing form...
|
|
</p>
|
|
)}
|
|
{submitStatus === "error" && (
|
|
<p className="text-sm text-red-600">
|
|
Submission failed. Please check your details and try again.
|
|
</p>
|
|
)}
|
|
</div>
|
|
</form>
|
|
);
|
|
}
|