mirror of
https://github.com/OwethuManagedServices/oms-website-nextjs.git
synced 2025-12-17 17:18:09 +00:00
displaying vacancy completed
This commit is contained in:
@ -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}
|
||||||
|
|||||||
@ -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>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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
126
components/ui/Modal.tsx
Normal 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;
|
||||||
Reference in New Issue
Block a user