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

@ -45,7 +45,7 @@ export default async function VacancyDetailsPage({
); );
return ( return (
<div className="bg-white text-gray-800 font-poppins overflow-x-hidden min-h-screen py-12 md:py-5"> <div className="bg-white dark:bg-black text-gray-800 font-poppins overflow-x-hidden min-h-screen py-12 md:py-5">
<VacancyClientContent <VacancyClientContent
vacancy={vacancy} vacancy={vacancy}
shareUrl={shareUrl} shareUrl={shareUrl}

View File

@ -12,7 +12,7 @@ export const ListSection = ({
if (!items || items.length === 0) return null; if (!items || items.length === 0) return null;
return ( return (
<div className="mb-8"> <div className="mb-8">
<h3 className="flex items-center gap-2 text-xl font-semibold mb-3 text-gray-900 font-poppins border-b border-gray-200 pb-2"> <h3 className="flex items-center gap-2 text-xl font-semibold mb-3 text-gray-900 dark:text-white font-poppins border-b border-gray-200 pb-2">
{Icon && ( {Icon && (
<Icon <Icon
className="h-5 w-5 text-primary" className="h-5 w-5 text-primary"
@ -21,7 +21,7 @@ export const ListSection = ({
)} )}
{title} {title}
</h3> </h3>
<ul className="list-disc space-y-2 pl-6 text-gray-700 font-poppins text-sm leading-relaxed"> <ul className="list-disc space-y-2 pl-6 text-gray-700 dark:text-white font-poppins text-sm leading-relaxed">
{items.map((item, index) => ( {items.map((item, index) => (
<li key={index}>{item}</li> <li key={index}>{item}</li>
))} ))}

View File

@ -9,7 +9,7 @@ export const MetadataItem = ({
label: string; label: string;
value: React.ReactNode; value: React.ReactNode;
}) => ( }) => (
<div className="flex items-start space-x-2 text-sm text-gray-700 font-poppins"> <div className="flex items-start space-x-2 text-sm text-gray-700 dark:text-white font-poppins">
<Icon <Icon
className="h-5 w-5 mt-0.5 flex-shrink-0 text-primary" className="h-5 w-5 mt-0.5 flex-shrink-0 text-primary"
aria-hidden="true" aria-hidden="true"

View File

@ -1,6 +1,8 @@
"use client"; "use client";
import { useState, useRef, useEffect } from "react"; import {
useState /* useRef, useEffect - Remove these if no longer needed */,
} from "react"; // Updated imports
import Link from "next/link"; import Link from "next/link";
import { Vacancy } from "@/types"; import { Vacancy } from "@/types";
import VacancyApplicationForm from "@/components/VacancyApplicationForm"; import VacancyApplicationForm from "@/components/VacancyApplicationForm";
@ -28,7 +30,8 @@ import Image from "next/image";
import { MetadataItem } from "./MetadataItem"; import { MetadataItem } from "./MetadataItem";
import { Badge } from "./Badge"; import { Badge } from "./Badge";
import { ListSection } from "./ListSection"; import { ListSection } from "./ListSection";
import Button from "@/components/ui/Button"; import Button from "@/components/ui/Button"; // Assuming you have a Button component
import Modal from "@/components/ui/Modal";
interface VacancyClientContentProps { interface VacancyClientContentProps {
vacancy: Vacancy & { vacancy: Vacancy & {
@ -44,56 +47,62 @@ export default function VacancyClientContent({
shareUrl, shareUrl,
shareTitle, shareTitle,
}: VacancyClientContentProps) { }: VacancyClientContentProps) {
const [showApplyForm, setShowApplyForm] = useState(false); // State to control modal visibility
const applyFormRef = useRef<HTMLDivElement>(null); const [isApplyModalOpen, setIsApplyModalOpen] = useState(false);
// const applyFormRef = useRef<HTMLDivElement>(null); // Remove this ref
// Remove the useEffect for scrolling
const handleApplyClick = () => { const handleOpenApplyModal = () => {
setShowApplyForm(true); setIsApplyModalOpen(true);
}; };
useEffect(() => { const handleCloseApplyModal = () => {
if (showApplyForm && applyFormRef.current) { setIsApplyModalOpen(false);
setTimeout(() => { };
applyFormRef.current?.scrollIntoView({
behavior: "smooth",
block: "start",
});
}, 100);
}
}, [showApplyForm]);
return ( return (
// Container is now within the client component <div className="container mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div className="container mx-auto px-6"> {" "}
{/* --- Re-include the FULL Company Header --- */} {/* Adjusted padding */}
{/* --- Company Header --- */}
{vacancy.company && ( {vacancy.company && (
<div className="mb-10 flex flex-col sm:flex-row items-center justify-between gap-6 p-6 bg-gray-50 shadow-md rounded-lg border border-gray-200"> <div className="mb-10 flex flex-col sm:flex-row items-center justify-between gap-6 p-6 bg-gray-50 dark:bg-gray-800 shadow-md rounded-lg border border-gray-200 dark:border-gray-700">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4 flex-grow min-w-0">
{" "}
{/* Added flex-grow and min-w-0 for wrapping */}
{vacancy.company.logoUrl ? ( {vacancy.company.logoUrl ? (
<Image <Image
src={vacancy.company.logoUrl} src={vacancy.company.logoUrl}
alt={`${vacancy.company.name} Logo`} alt={`${vacancy.company.name} Logo`}
width={64} // Add width/height for better layout shift prevention
height={64}
className="h-16 w-16 object-contain rounded-md flex-shrink-0" className="h-16 w-16 object-contain rounded-md flex-shrink-0"
/> />
) : ( ) : (
<div className="h-16 w-16 bg-gray-200 rounded-md flex items-center justify-center flex-shrink-0"> <div className="h-16 w-16 bg-gray-200 dark:bg-gray-700 rounded-md flex items-center justify-center flex-shrink-0">
<FaBuilding <FaBuilding
className="h-8 w-8 text-primary" className="h-8 w-8 text-primary"
style={{ color: COLORS.primary }} style={{ color: COLORS.primary }}
/> />
</div> </div>
)} )}
<div> <div className="min-w-0">
<h1 className="text-3xl md:text-4xl font-bold text-gray-900"> {" "}
{/* Added min-w-0 here too */}
<h1 className="text-2xl md:text-3xl font-bold text-gray-900 dark:text-white truncate">
{" "}
{/* Consider truncate */}
{vacancy.title} {vacancy.title}
</h1> </h1>
<p className="text-lg text-gray-700">at {vacancy.company.name}</p> <p className="text-lg text-gray-700 dark:text-gray-300">
at {vacancy.company.name}
</p>
{vacancy.company.websiteUrl && ( {vacancy.company.websiteUrl && (
<Link <Link
href={vacancy.company.websiteUrl} href={vacancy.company.websiteUrl}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="text-sm text-blue-600 hover:text-blue-800 inline-flex items-center gap-1 group mt-1" className="text-sm text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300 inline-flex items-center gap-1 group mt-1"
> >
Visit website{" "} Visit website{" "}
<FaLink className="h-4 w-4 transition-transform duration-200 group-hover:translate-x-1" /> <FaLink className="h-4 w-4 transition-transform duration-200 group-hover:translate-x-1" />
@ -101,45 +110,57 @@ export default function VacancyClientContent({
)} )}
</div> </div>
</div> </div>
{/* Apply Button in Header (conditional) */} {/* Apply Button in Header */}
{!showApplyForm && ( <Button
<button variant="primary" // Use your Button component if available
onClick={handleApplyClick} onClick={handleOpenApplyModal}
className="inline-block bg-gray-800 text-white font-bold py-3 px-8 rounded-md hover:bg-gray-900 transition-colors duration-300 whitespace-nowrap" className="flex-shrink-0 whitespace-nowrap" // Prevent shrinking/wrapping
> >
Apply Now Apply Now
</button> </Button>
)}
</div> </div>
)} )}
{/* --- End Company Header --- */} {/* --- End Company Header --- */}
{/* --- Main Grid Layout --- */} {/* --- Main Grid Layout --- */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-10"> <div className="grid grid-cols-1 lg:grid-cols-3 gap-8 lg:gap-10">
{/* --- Main Content Area (Left Column) --- */} {/* --- Main Content Area (Left Column) --- */}
<div className="lg:col-span-2 bg-white shadow-lg rounded-lg p-6 md:p-8 border border-gray-200"> <div className="lg:col-span-2 bg-white dark:bg-gray-800 shadow-lg rounded-lg p-6 md:p-8 border border-gray-200 dark:border-gray-700">
{/* Title if no company header */} {/* Title if no company header */}
{!vacancy.company && ( {!vacancy.company && (
<h1 className="text-3xl md:text-4xl font-bold mb-6 text-gray-900"> <div className="flex justify-between items-start mb-6">
{vacancy.title} <h1 className="text-3xl md:text-4xl font-bold text-gray-900 dark:text-white">
</h1> {vacancy.title}
</h1>
{/* Apply Button if no header */}
<Button
variant="primary"
onClick={handleOpenApplyModal}
className="ml-4 flex-shrink-0 whitespace-nowrap"
>
Apply Now
</Button>
</div>
)} )}
{/* Job Description */} {/* Job Description */}
<div className="mb-8"> <div className="mb-8">
<h3 className="flex items-center gap-2 text-xl font-semibold mb-3 text-gray-900 border-b border-gray-200 pb-2"> <h3 className="flex items-center gap-2 text-xl font-semibold mb-3 text-gray-900 dark:text-white border-b border-gray-200 dark:border-gray-700 pb-2">
<FaInfoCircle <FaInfoCircle
className="h-5 w-5 text-primary" className="h-5 w-5 text-primary"
style={{ color: COLORS.primary }} style={{ color: COLORS.primary }}
/>{" "} />{" "}
Job Description Job Description
</h3> </h3>
<div className="prose prose-sm sm:prose-base max-w-none text-gray-700 font-poppins leading-relaxed"> <div
<p>{vacancy.description}</p> className="prose prose-sm sm:prose-base max-w-none text-gray-700 dark:text-gray-300 font-poppins leading-relaxed prose-headings:text-gray-900 prose-headings:dark:text-white prose-a:text-blue-600 dark:prose-a:text-blue-400 prose-strong:text-gray-800 dark:prose-strong:text-gray-200"
dangerouslySetInnerHTML={{ __html: vacancy.description || "" }} // Use dangerouslySetInnerHTML if description is HTML
>
{/* Or render as text if plain: <p>{vacancy.description}</p> */}
</div> </div>
</div> </div>
{/* --- All List Sections (Responsibilities, Qualifications, Skills, Benefits) --- */} {/* --- List Sections (Responsibilities, Qualifications, Skills, Benefits) --- */}
{/* Assuming ListSection renders conditionally if items are empty */}
<ListSection <ListSection
title="Responsibilities" title="Responsibilities"
items={vacancy.responsibilities} items={vacancy.responsibilities}
@ -156,10 +177,10 @@ export default function VacancyClientContent({
icon={FaStar} icon={FaStar}
/> />
{/* Skills Section - RE-INCLUDED */} {/* Skills Section */}
{vacancy.skills && vacancy.skills.length > 0 && ( {vacancy.skills && vacancy.skills.length > 0 && (
<div className="mb-8"> <div className="mb-8">
<h3 className="flex items-center gap-2 text-xl font-semibold mb-4 text-gray-900 border-b border-gray-200 pb-2"> <h3 className="flex items-center gap-2 text-xl font-semibold mb-4 text-gray-900 dark:text-white border-b border-gray-200 dark:border-gray-700 pb-2">
<FaTools <FaTools
className="h-5 w-5 text-primary" className="h-5 w-5 text-primary"
style={{ color: COLORS.primary }} style={{ color: COLORS.primary }}
@ -174,17 +195,17 @@ export default function VacancyClientContent({
</div> </div>
)} )}
{/* Benefits Section - RE-INCLUDED */} {/* Benefits Section */}
{vacancy.benefits && vacancy.benefits.length > 0 && ( {vacancy.benefits && vacancy.benefits.length > 0 && (
<div className="mb-8"> <div className="mb-8">
<h3 className="flex items-center gap-2 text-xl font-semibold mb-3 text-gray-900 border-b border-gray-200 pb-2"> <h3 className="flex items-center gap-2 text-xl font-semibold mb-3 text-gray-900 dark:text-white border-b border-gray-200 dark:border-gray-700 pb-2">
<FaGift <FaGift
className="h-5 w-5 text-primary" className="h-5 w-5 text-primary"
style={{ color: COLORS.primary }} style={{ color: COLORS.primary }}
/>{" "} />{" "}
Benefits Benefits
</h3> </h3>
<ul className="grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-2 text-gray-700 mt-3 text-sm"> <ul className="grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-2 text-gray-700 dark:text-gray-300 mt-3 text-sm">
{vacancy.benefits.map((item, index) => ( {vacancy.benefits.map((item, index) => (
<li <li
key={index} key={index}
@ -199,22 +220,20 @@ export default function VacancyClientContent({
)} )}
{/* --- End List Sections --- */} {/* --- End List Sections --- */}
{/* Apply button below main content (conditional) */} {/* Apply button below main content (redundant if header button exists, keep or remove as needed) */}
{!showApplyForm && ( <div className="mt-10 text-center lg:text-left">
<div className="mt-10 text-center lg:text-left"> <Button variant="primary" onClick={handleOpenApplyModal}>
<Button variant="primary" onClick={handleApplyClick}> Apply for this Position
Apply for this Position </Button>
</Button> </div>
</div>
)}
</div> </div>
{/* --- End Main Content Area --- */} {/* --- End Main Content Area --- */}
{/* --- Sidebar (Right Column) --- */} {/* --- Sidebar (Right Column) --- */}
<div className="lg:col-span-1 space-y-8"> <div className="lg:col-span-1 space-y-8">
{/* Metadata Card - Exactly as before */} {/* Metadata Card */}
<div className="bg-gray-50 shadow-md rounded-lg p-6 border-l-4 border-primary"> <div className="bg-gray-50 dark:bg-gray-800 shadow-md rounded-lg p-6 border-l-4 border-primary dark:border-gold-500">
<h3 className="text-xl font-semibold mb-5 text-gray-900 font-poppins"> <h3 className="text-xl font-semibold mb-5 text-gray-900 dark:text-white font-poppins">
Job Overview Job Overview
</h3> </h3>
<div className="space-y-4"> <div className="space-y-4">
@ -248,7 +267,7 @@ export default function VacancyClientContent({
icon={FaDollarSign} icon={FaDollarSign}
label="Salary" label="Salary"
value={ value={
<span className="font-semibold text-gray-800"> <span className="font-semibold text-gray-800 dark:text-gray-200">
{vacancy.salary.min.toLocaleString()} -{" "} {vacancy.salary.min.toLocaleString()} -{" "}
{vacancy.salary.max.toLocaleString()}{" "} {vacancy.salary.max.toLocaleString()}{" "}
{vacancy.salary.currency} {vacancy.salary.period} {vacancy.salary.currency} {vacancy.salary.period}
@ -271,9 +290,9 @@ export default function VacancyClientContent({
</div> </div>
</div> </div>
{/* Share Card - Exactly as before */} {/* Share Card */}
<div className="bg-gray-50 shadow-md rounded-lg p-6 border-l-4 border-primary"> <div className="bg-gray-50 dark:bg-gray-800 shadow-md rounded-lg p-6 border-l-4 border-primary dark:border-gold-500">
<h3 className="text-xl font-semibold mb-4 text-gray-900 flex items-center gap-2 font-poppins"> <h3 className="text-xl font-semibold mb-4 text-gray-900 dark:text-white flex items-center gap-2 font-poppins">
<FaShareAlt <FaShareAlt
className="h-5 w-5 text-primary" className="h-5 w-5 text-primary"
style={{ color: COLORS.primary }} style={{ color: COLORS.primary }}
@ -287,7 +306,7 @@ export default function VacancyClientContent({
)}`} )}`}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="text-blue-600 hover:underline" className="text-blue-600 hover:underline dark:text-blue-400 dark:hover:text-blue-300"
> >
LinkedIn LinkedIn
</a> </a>
@ -297,7 +316,7 @@ export default function VacancyClientContent({
)}&text=${shareTitle}`} )}&text=${shareTitle}`}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="text-blue-600 hover:underline" className="text-blue-600 hover:underline dark:text-blue-400 dark:hover:text-blue-300"
> >
Twitter Twitter
</a> </a>
@ -305,37 +324,26 @@ export default function VacancyClientContent({
href={`mailto:?subject=${shareTitle}&body=Check out this job opening: ${encodeURIComponent( href={`mailto:?subject=${shareTitle}&body=Check out this job opening: ${encodeURIComponent(
shareUrl shareUrl
)}`} )}`}
className="text-gray-600 hover:underline" className="text-gray-600 hover:underline dark:text-gray-400 dark:hover:text-gray-300"
> >
Email Email
</a> </a>
</div> </div>
</div> </div>
{/* --- Conditionally Rendered Application Form Section --- */}
{showApplyForm && (
<div
ref={applyFormRef}
id="apply-form-section"
className="bg-white shadow-lg rounded-lg p-6 md:p-8 border border-gray-200 scroll-mt-20 lg:mt-0"
>
{" "}
{/* scroll-mt helps anchor links, lg:mt-0 aligns with other cards */}
<h2 className="text-2xl font-bold mb-6 text-center text-gray-900 font-poppins">
Apply for: {vacancy.title}
</h2>
<VacancyApplicationForm
vacancyId={vacancy.id}
vacancyTitle={vacancy.title}
// Pass a function to close the form if needed: onFormClose={() => setShowApplyForm(false)}
/>
</div>
)}
{/* --- End Form Section --- */}
</div> </div>
{/* --- End Sidebar --- */}
</div> </div>
{/* --- End Main Grid --- */} <Modal
</div> // End Container isOpen={isApplyModalOpen}
onClose={handleCloseApplyModal}
title={`Apply for: ${vacancy.title}`}
size="4xl"
>
<VacancyApplicationForm
vacancyId={vacancy.id}
vacancyTitle={vacancy.title}
onClose={handleCloseApplyModal}
/>
</Modal>
</div>
); );
} }

View File

@ -244,12 +244,8 @@ const HeaderClient = ({
</div> </div>
<div className="hover:text-opacity-80 transition-opacity"> <div className="hover:text-opacity-80 transition-opacity">
<DropdownMenu trigger={<span>Join Our Team</span>}> <DropdownMenu trigger={<span>Join Our Team</span>}>
<DropdownLink href="/join-us/vacancies"> <DropdownLink href="/vacancies">Vacancies</DropdownLink>
Vacancies <DropdownLink href="/portal">Recruitment Portal</DropdownLink>
</DropdownLink>
<DropdownLink href="/join-us/portal">
Recruitment Portal
</DropdownLink>
</DropdownMenu> </DropdownMenu>
</div> </div>
</nav> </nav>

View File

@ -1,46 +1,81 @@
// src/components/VacancyApplicationForm.tsx (Example structure and styling) "use client";
"use client"; // This likely needs to be a client component for form handling
import React, { useState, useCallback, ChangeEvent, DragEvent } from "react"; 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 { interface VacancyApplicationFormProps {
vacancyId: string; vacancyId: string;
vacancyTitle: string; vacancyTitle: string;
// onFormClose?: () => void; // Optional: If you add a close button onClose: () => void;
} }
export default function VacancyApplicationForm({ export default function VacancyApplicationForm({
vacancyId, vacancyId,
vacancyTitle, vacancyTitle,
onClose,
}: VacancyApplicationFormProps) { }: VacancyApplicationFormProps) {
const [formData, setFormData] = useState({ const {
name: "", register,
email: "", handleSubmit,
phone: "", setValue,
linkedin: "", watch,
coverLetter: "", formState: { errors, isSubmitting },
// Add other fields as needed 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 [isDragging, setIsDragging] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const [submitStatus, setSubmitStatus] = useState< const [submitStatus, setSubmitStatus] = useState<
"idle" | "success" | "error" "idle" | "success" | "error"
>("idle"); >("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>) => { const handleFileChange = (e: ChangeEvent<HTMLInputElement>) => {
if (e.target.files && e.target.files.length > 0) { if (e.target.files && e.target.files.length > 0) {
setResumeFile(e.target.files[0]); setValue("resume", e.target.files[0], { shouldValidate: true });
e.target.value = ""; // Reset input value for potential re-upload e.target.value = "";
} }
}; };
@ -58,161 +93,190 @@ export default function VacancyApplicationForm({
const handleDragOver = useCallback( const handleDragOver = useCallback(
(e: DragEvent<HTMLDivElement>) => { (e: DragEvent<HTMLDivElement>) => {
e.preventDefault(); // Necessary to allow drop e.preventDefault();
e.stopPropagation(); e.stopPropagation();
if (!isDragging) setIsDragging(true); // Ensure state is true while over if (!isDragging) setIsDragging(true);
}, },
[isDragging] [isDragging]
); );
const handleDrop = useCallback((e: DragEvent<HTMLDivElement>) => { const handleDrop = useCallback(
e.preventDefault(); (e: DragEvent<HTMLDivElement>) => {
e.stopPropagation(); e.preventDefault();
setIsDragging(false); e.stopPropagation();
if (e.dataTransfer.files && e.dataTransfer.files.length > 0) { setIsDragging(false);
setResumeFile(e.dataTransfer.files[0]); if (e.dataTransfer.files && e.dataTransfer.files.length > 0) {
e.dataTransfer.clearData(); setValue("resume", e.dataTransfer.files[0], { shouldValidate: true });
} e.dataTransfer.clearData();
}, []); }
},
[setValue]
);
const removeFile = () => { const removeFile = () => {
setResumeFile(null); setValue("resume", null, { shouldValidate: true });
}; };
// --- End File Input Handling ---
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => { const onSubmit: SubmitHandler<ApplicationFormData> = async (data) => {
e.preventDefault();
setIsSubmitting(true);
setSubmitStatus("idle"); setSubmitStatus("idle");
const formData = new FormData();
const data = new FormData(); formData.append("vacancyId", vacancyId);
data.append("vacancyId", vacancyId); formData.append("vacancyTitle", vacancyTitle);
data.append("vacancyTitle", vacancyTitle); formData.append("name", data.name);
data.append("name", formData.name); formData.append("email", data.email);
data.append("email", formData.email); if (data.phone) formData.append("phone", data.phone);
data.append("phone", formData.phone); if (data.linkedin) formData.append("linkedin", data.linkedin);
data.append("linkedin", formData.linkedin); if (data.coverLetter) formData.append("coverLetter", data.coverLetter);
data.append("coverLetter", formData.coverLetter); if (data.resume) formData.append("resume", data.resume, data.resume.name);
if (resumeFile) {
data.append("resume", resumeFile, resumeFile.name);
}
try { try {
// Replace with your actual API endpoint for form submission
const response = await fetch("/api/apply", { const response = await fetch("/api/apply", {
method: "POST", method: "POST",
body: data, // FormData handles multipart/form-data automatically body: formData,
}); });
if (!response.ok)
if (!response.ok) { throw new Error(`Submission failed: ${response.statusText}`);
throw new Error("Submission failed");
}
setSubmitStatus("success"); setSubmitStatus("success");
// Optionally reset form: reset();
// setFormData({ name: '', email: '', ... }); setTimeout(() => {
// setResumeFile(null); onClose();
// Optionally close form: onFormClose?.(); setSubmitStatus("idle");
}, 1500);
} catch (error) { } catch (error) {
console.error("Application submission error:", error); console.error(error);
setSubmitStatus("error"); setSubmitStatus("error");
} finally {
setIsSubmitting(false);
} }
}; };
// --- Base Input Styling ---
const inputBaseStyle = 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"; "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 ( return (
<form onSubmit={handleSubmit} className="space-y-6 font-poppins"> <form onSubmit={handleSubmit(onSubmit)} className="space-y-6 font-poppins">
{/* Example Fields */} {/* Row 1: Name & Email */}
<div> <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<label htmlFor="name" className="text-gray-700 dark:text-gray-200"> {/* Name Field */}
Full Name * <div>
</label> <label htmlFor="name" className="text-gray-700 dark:text-gray-200">
<input Full Name *
id="name" </label>
name="name" <input
type="text" id="name"
required type="text"
className={inputBaseStyle} {...register("name")}
value={formData.name} className={`${inputBaseStyle} ${errors.name ? errorStyle : ""}`}
onChange={handleInputChange} aria-invalid={errors.name ? "true" : "false"}
/> />
</div> {errors.name && (
<div> <p role="alert" className={errorMessageStyle}>
<label htmlFor="email" className="text-gray-700 dark:text-gray-200"> {errors.name.message}
Email Address * </p>
</label> )}
<input </div>
id="email"
name="email" {/* Email Field */}
type="email" <div>
required <label htmlFor="email" className="text-gray-700 dark:text-gray-200">
className={inputBaseStyle} Email Address *
value={formData.email} </label>
onChange={handleInputChange} <input
/> id="email"
</div> type="email"
<div> {...register("email")}
<label htmlFor="phone" className="text-gray-700 dark:text-gray-200"> className={`${inputBaseStyle} ${errors.email ? errorStyle : ""}`}
Phone Number aria-invalid={errors.email ? "true" : "false"}
</label> />
<input {errors.email && (
id="phone" <p role="alert" className={errorMessageStyle}>
name="phone" {errors.email.message}
type="tel" </p>
className={inputBaseStyle} )}
value={formData.phone} </div>
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"
/>
</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> <div>
<label className="text-gray-700 dark:text-gray-200 block mb-2"> <label className="text-gray-700 dark:text-gray-200 block mb-2">
Resume/CV * Resume/CV *
</label> </label>
<input
id="resume-upload-hidden"
type="file"
onChange={handleFileChange}
accept=".pdf,.doc,.docx,.txt"
className="hidden"
aria-hidden="true"
/>
<div <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 ${ 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 isDragging
? "border-gold-500 bg-gold-50" ? "border-gold-500 bg-gold-50"
: errors.resume
? "border-red-500"
: "border-gray-300 hover:bg-gray-50" : "border-gray-300 hover:bg-gray-50"
}`} }`}
onDragEnter={handleDragEnter} onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave} onDragLeave={handleDragLeave}
onDragOver={handleDragOver} onDragOver={handleDragOver}
onDrop={handleDrop} 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 <FaUpload
className={`w-8 h-8 mb-3 ${ className={`w-8 h-8 mb-3 ${
isDragging ? "text-gold-600" : "text-gray-400" isDragging ? "text-gold-600" : "text-gray-400"
@ -229,10 +293,15 @@ export default function VacancyApplicationForm({
<p className="text-xs text-gray-500 text-center"> <p className="text-xs text-gray-500 text-center">
or click to browse or click to browse
</p> </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> </div>
{errors.resume && (
{/* Display Selected File */} <p role="alert" className={errorMessageStyle}>
{(errors.resume as unknown as { message: string }).message}
</p>
)}
{resumeFile && ( {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="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"> <div className="flex items-center gap-2">
@ -255,8 +324,8 @@ export default function VacancyApplicationForm({
</div> </div>
)} )}
</div> </div>
{/* --- End File Upload Area --- */}
{/* Cover Letter Field */}
<div> <div>
<label <label
htmlFor="coverLetter" htmlFor="coverLetter"
@ -266,34 +335,37 @@ export default function VacancyApplicationForm({
</label> </label>
<textarea <textarea
id="coverLetter" id="coverLetter"
name="coverLetter"
rows={4} rows={4}
className={`${inputBaseStyle} resize-vertical`} {...register("coverLetter")} // Register with RHF
value={formData.coverLetter} className={`${inputBaseStyle} resize-vertical ${
onChange={handleInputChange} errors.coverLetter ? errorStyle : ""
}`}
aria-invalid={errors.coverLetter ? "true" : "false"}
></textarea> ></textarea>
{errors.coverLetter && (
<p role="alert" className={errorMessageStyle}>
{errors.coverLetter.message}
</p>
)}
</div> </div>
{/* Submit Button & Status */} {/* Submit Button & Status */}
<div className="flex flex-col items-center space-y-3 pt-4"> <div className="flex flex-col items-center space-y-3 pt-4">
<button <Button
type="submit" type="submit"
disabled={ disabled={isSubmitting || submitStatus === "success"} // Disable while submitting or on success
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"
> >
{isSubmitting ? "Submitting..." : "Submit Application"} {isSubmitting ? "Submitting..." : "Submit Application"}
</button> </Button>
{submitStatus === "success" && ( {submitStatus === "success" && (
<p className="text-sm text-green-600"> <p className="text-sm text-green-600">
Application submitted successfully! Application submitted successfully! Closing form...
</p> </p>
)} )}
{submitStatus === "error" && ( {submitStatus === "error" && (
<p className="text-sm text-red-600"> <p className="text-sm text-red-600">
Submission failed. Please try again. Submission failed. Please check your details and try again.
</p> </p>
)} )}
</div> </div>

126
components/ui/Modal.tsx Normal file
View File

@ -0,0 +1,126 @@
// src/components/ui/Modal.tsx
import React, { ReactNode, useEffect } from "react";
import { FaTimes } from "react-icons/fa";
interface ModalProps {
isOpen: boolean;
onClose: () => void;
title?: string;
children: ReactNode;
size?: "sm" | "md" | "lg" | "xl" | "2xl" | "3xl" | "4xl" | "5xl"; // Optional size control
}
const Modal: React.FC<ModalProps> = ({
isOpen,
onClose,
title,
children,
size = "2xl", // Default size
}) => {
// Close modal on Escape key press
useEffect(() => {
const handleEscape = (event: KeyboardEvent) => {
if (event.key === "Escape") {
onClose();
}
};
if (isOpen) {
document.addEventListener("keydown", handleEscape);
}
// Cleanup listener on component unmount or when modal closes
return () => {
document.removeEventListener("keydown", handleEscape);
};
}, [isOpen, onClose]);
// Prevent scrolling on the body when the modal is open
useEffect(() => {
if (isOpen) {
document.body.style.overflow = "hidden";
} else {
document.body.style.overflow = "auto";
}
// Cleanup overflow style on component unmount
return () => {
document.body.style.overflow = "auto";
};
}, [isOpen]);
if (!isOpen) return null;
// Map size prop to Tailwind max-width classes
const sizeClasses = {
sm: "max-w-sm",
md: "max-w-md",
lg: "max-w-lg",
xl: "max-w-xl",
"2xl": "max-w-2xl",
"3xl": "max-w-3xl",
"4xl": "max-w-4xl",
"5xl": "max-w-5xl",
};
return (
// Backdrop
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-60 backdrop-blur-sm p-4 transition-opacity duration-300 ease-in-out"
onClick={onClose} // Close when clicking the backdrop
role="dialog"
aria-modal="true"
aria-labelledby={title ? "modal-title" : undefined}
>
{/* Modal Panel */}
<div
className={`relative w-full ${sizeClasses[size]} bg-white dark:bg-gray-800 rounded-lg shadow-xl overflow-hidden transition-all duration-300 ease-in-out transform scale-95 opacity-0 animate-modal-appear`}
onClick={(e) => e.stopPropagation()} // Prevent clicks inside the modal from closing it
>
{/* Modal Header (Optional) */}
{title && (
<div className="flex items-center justify-between p-4 sm:p-5 border-b border-gray-200 dark:border-gray-700">
{title && (
<h2
id="modal-title"
className="text-lg font-semibold text-gray-900 dark:text-white font-poppins"
>
{title}
</h2>
)}
<button
onClick={onClose}
className="text-gray-400 bg-transparent hover:bg-gray-200 hover:text-gray-900 rounded-lg text-sm p-1.5 ml-auto inline-flex items-center dark:hover:bg-gray-600 dark:hover:text-white"
aria-label="Close modal"
>
<FaTimes className="w-5 h-5" />
</button>
</div>
)}
{/* Modal Body */}
<div className="p-5 sm:p-6 space-y-4 max-h-[80vh] overflow-y-auto">
{" "}
{/* Added max-height and scroll */}
{children}
</div>
</div>
{/* Add animation keyframes to your global CSS (e.g., src/app/globals.css) */}
<style jsx global>{`
@keyframes modal-appear {
0% {
opacity: 0;
transform: scale(0.95);
}
100% {
opacity: 1;
transform: scale(1);
}
}
.animate-modal-appear {
animation: modal-appear 0.3s ease-out forwards;
}
`}</style>
</div>
);
};
export default Modal;