mirror of
https://github.com/OwethuManagedServices/oms-website-nextjs.git
synced 2025-12-17 18:58:10 +00:00
Vacancy added
This commit is contained in:
56
app/(website)/vacancies/[slug]/page.tsx
Normal file
56
app/(website)/vacancies/[slug]/page.tsx
Normal file
@ -0,0 +1,56 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import { Vacancy } from "@/types";
|
||||
import VacancyClientContent from "../_components/VacancyClientContent";
|
||||
|
||||
interface ExtendedVacancy extends Vacancy {
|
||||
company?: {
|
||||
name: string;
|
||||
logoUrl?: string;
|
||||
websiteUrl?: string;
|
||||
};
|
||||
skills?: string[];
|
||||
}
|
||||
|
||||
async function getVacancy(slug: string): Promise<ExtendedVacancy | null> {
|
||||
const res = await fetch(`http://localhost:3000/api/vacancies/${slug}`, {
|
||||
cache: "no-store",
|
||||
});
|
||||
if (!res.ok) {
|
||||
if (res.status === 404) return null;
|
||||
console.error(`Failed to fetch vacancy ${slug}: ${res.statusText}`);
|
||||
throw new Error("Failed to fetch vacancy details");
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
interface VacancyDetailsPageProps {
|
||||
params: { slug: string };
|
||||
}
|
||||
|
||||
export default async function VacancyDetailsPage({
|
||||
params,
|
||||
}: VacancyDetailsPageProps) {
|
||||
const { slug } = await params;
|
||||
const vacancy = await getVacancy(slug);
|
||||
|
||||
if (!vacancy) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const shareUrl = `${process.env.WEBSITE_URL}/vacancies/${params.slug}`;
|
||||
const shareTitle = encodeURIComponent(
|
||||
`Job Opening: ${vacancy.title} at ${
|
||||
vacancy.company?.name || "Owethu Managed Services"
|
||||
}`
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="bg-white text-gray-800 font-poppins overflow-x-hidden min-h-screen py-12 md:py-5">
|
||||
<VacancyClientContent
|
||||
vacancy={vacancy}
|
||||
shareUrl={shareUrl}
|
||||
shareTitle={shareTitle}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
12
app/(website)/vacancies/_components/Badge.tsx
Normal file
12
app/(website)/vacancies/_components/Badge.tsx
Normal file
@ -0,0 +1,12 @@
|
||||
export const Badge = ({
|
||||
children,
|
||||
icon: Icon,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
icon?: React.ElementType;
|
||||
}) => (
|
||||
<span className="inline-flex items-center gap-1 rounded-md bg-gray-100 px-2 py-1 text-xs font-medium text-gray-700 ring-1 ring-inset ring-gray-200 font-poppins">
|
||||
{Icon && <Icon className="h-3 w-3" />}
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
31
app/(website)/vacancies/_components/ListSection.tsx
Normal file
31
app/(website)/vacancies/_components/ListSection.tsx
Normal file
@ -0,0 +1,31 @@
|
||||
import { COLORS } from "@/constants";
|
||||
|
||||
export const ListSection = ({
|
||||
title,
|
||||
items,
|
||||
icon: Icon,
|
||||
}: {
|
||||
title: string;
|
||||
items?: string[];
|
||||
icon?: React.ElementType;
|
||||
}) => {
|
||||
if (!items || items.length === 0) return null;
|
||||
return (
|
||||
<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">
|
||||
{Icon && (
|
||||
<Icon
|
||||
className="h-5 w-5 text-primary"
|
||||
style={{ color: COLORS.primary }}
|
||||
/>
|
||||
)}
|
||||
{title}
|
||||
</h3>
|
||||
<ul className="list-disc space-y-2 pl-6 text-gray-700 font-poppins text-sm leading-relaxed">
|
||||
{items.map((item, index) => (
|
||||
<li key={index}>{item}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
22
app/(website)/vacancies/_components/MetadataItem.tsx
Normal file
22
app/(website)/vacancies/_components/MetadataItem.tsx
Normal file
@ -0,0 +1,22 @@
|
||||
import { COLORS } from "@/constants";
|
||||
|
||||
export const MetadataItem = ({
|
||||
icon: Icon,
|
||||
label,
|
||||
value,
|
||||
}: {
|
||||
icon: React.ElementType;
|
||||
label: string;
|
||||
value: React.ReactNode;
|
||||
}) => (
|
||||
<div className="flex items-start space-x-2 text-sm text-gray-700 font-poppins">
|
||||
<Icon
|
||||
className="h-5 w-5 mt-0.5 flex-shrink-0 text-primary"
|
||||
aria-hidden="true"
|
||||
style={{ color: COLORS.primary }}
|
||||
/>
|
||||
<div>
|
||||
<span className="font-semibold">{label}:</span> {value}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
341
app/(website)/vacancies/_components/VacancyClientContent.tsx
Normal file
341
app/(website)/vacancies/_components/VacancyClientContent.tsx
Normal file
@ -0,0 +1,341 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import Link from "next/link";
|
||||
import { Vacancy } from "@/types";
|
||||
import VacancyApplicationForm from "@/components/VacancyApplicationForm";
|
||||
|
||||
import {
|
||||
FaMapMarkerAlt,
|
||||
FaBriefcase,
|
||||
FaClock,
|
||||
FaCalendarAlt,
|
||||
FaDollarSign,
|
||||
FaGraduationCap,
|
||||
FaShareAlt,
|
||||
FaCheckCircle,
|
||||
FaBuilding,
|
||||
FaLink,
|
||||
FaListUl,
|
||||
FaInfoCircle,
|
||||
FaStar,
|
||||
FaGift,
|
||||
FaTools,
|
||||
} from "react-icons/fa";
|
||||
import { formatDate } from "@/lib/helpers";
|
||||
import { COLORS } from "@/constants";
|
||||
import Image from "next/image";
|
||||
import { MetadataItem } from "./MetadataItem";
|
||||
import { Badge } from "./Badge";
|
||||
import { ListSection } from "./ListSection";
|
||||
import Button from "@/components/ui/Button";
|
||||
|
||||
interface VacancyClientContentProps {
|
||||
vacancy: Vacancy & {
|
||||
company?: { name: string; logoUrl?: string; websiteUrl?: string };
|
||||
skills?: string[];
|
||||
};
|
||||
shareUrl: string;
|
||||
shareTitle: string;
|
||||
}
|
||||
|
||||
export default function VacancyClientContent({
|
||||
vacancy,
|
||||
shareUrl,
|
||||
shareTitle,
|
||||
}: VacancyClientContentProps) {
|
||||
const [showApplyForm, setShowApplyForm] = useState(false);
|
||||
const applyFormRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const handleApplyClick = () => {
|
||||
setShowApplyForm(true);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (showApplyForm && applyFormRef.current) {
|
||||
setTimeout(() => {
|
||||
applyFormRef.current?.scrollIntoView({
|
||||
behavior: "smooth",
|
||||
block: "start",
|
||||
});
|
||||
}, 100);
|
||||
}
|
||||
}, [showApplyForm]);
|
||||
|
||||
return (
|
||||
// Container is now within the client component
|
||||
<div className="container mx-auto px-6">
|
||||
{/* --- Re-include the FULL Company Header --- */}
|
||||
{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="flex items-center gap-4">
|
||||
{vacancy.company.logoUrl ? (
|
||||
<Image
|
||||
src={vacancy.company.logoUrl}
|
||||
alt={`${vacancy.company.name} Logo`}
|
||||
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">
|
||||
<FaBuilding
|
||||
className="h-8 w-8 text-primary"
|
||||
style={{ color: COLORS.primary }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<h1 className="text-3xl md:text-4xl font-bold text-gray-900">
|
||||
{vacancy.title}
|
||||
</h1>
|
||||
<p className="text-lg text-gray-700">at {vacancy.company.name}</p>
|
||||
{vacancy.company.websiteUrl && (
|
||||
<Link
|
||||
href={vacancy.company.websiteUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-sm text-blue-600 hover:text-blue-800 inline-flex items-center gap-1 group mt-1"
|
||||
>
|
||||
Visit website{" "}
|
||||
<FaLink className="h-4 w-4 transition-transform duration-200 group-hover:translate-x-1" />
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{/* Apply Button in Header (conditional) */}
|
||||
{!showApplyForm && (
|
||||
<button
|
||||
onClick={handleApplyClick}
|
||||
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"
|
||||
>
|
||||
Apply Now
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{/* --- End Company Header --- */}
|
||||
|
||||
{/* --- Main Grid Layout --- */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-10">
|
||||
{/* --- 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">
|
||||
{/* Title if no company header */}
|
||||
{!vacancy.company && (
|
||||
<h1 className="text-3xl md:text-4xl font-bold mb-6 text-gray-900">
|
||||
{vacancy.title}
|
||||
</h1>
|
||||
)}
|
||||
|
||||
{/* Job Description */}
|
||||
<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">
|
||||
<FaInfoCircle
|
||||
className="h-5 w-5 text-primary"
|
||||
style={{ color: COLORS.primary }}
|
||||
/>{" "}
|
||||
Job Description
|
||||
</h3>
|
||||
<div className="prose prose-sm sm:prose-base max-w-none text-gray-700 font-poppins leading-relaxed">
|
||||
<p>{vacancy.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* --- All List Sections (Responsibilities, Qualifications, Skills, Benefits) --- */}
|
||||
<ListSection
|
||||
title="Responsibilities"
|
||||
items={vacancy.responsibilities}
|
||||
icon={FaListUl}
|
||||
/>
|
||||
<ListSection
|
||||
title="Required Qualifications"
|
||||
items={vacancy.requiredQualifications}
|
||||
icon={FaStar}
|
||||
/>
|
||||
<ListSection
|
||||
title="Preferred Qualifications"
|
||||
items={vacancy.preferredQualifications}
|
||||
icon={FaStar}
|
||||
/>
|
||||
|
||||
{/* Skills Section - RE-INCLUDED */}
|
||||
{vacancy.skills && vacancy.skills.length > 0 && (
|
||||
<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">
|
||||
<FaTools
|
||||
className="h-5 w-5 text-primary"
|
||||
style={{ color: COLORS.primary }}
|
||||
/>{" "}
|
||||
Skills
|
||||
</h3>
|
||||
<div className="flex flex-wrap gap-2 mt-3">
|
||||
{vacancy.skills.map((skill, index) => (
|
||||
<Badge key={index}>{skill}</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Benefits Section - RE-INCLUDED */}
|
||||
{vacancy.benefits && vacancy.benefits.length > 0 && (
|
||||
<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">
|
||||
<FaGift
|
||||
className="h-5 w-5 text-primary"
|
||||
style={{ color: COLORS.primary }}
|
||||
/>{" "}
|
||||
Benefits
|
||||
</h3>
|
||||
<ul className="grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-2 text-gray-700 mt-3 text-sm">
|
||||
{vacancy.benefits.map((item, index) => (
|
||||
<li
|
||||
key={index}
|
||||
className="flex items-center space-x-2 font-poppins"
|
||||
>
|
||||
<FaCheckCircle className="h-4 w-4 text-green-600 flex-shrink-0" />
|
||||
<span>{item}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
{/* --- End List Sections --- */}
|
||||
|
||||
{/* Apply button below main content (conditional) */}
|
||||
{!showApplyForm && (
|
||||
<div className="mt-10 text-center lg:text-left">
|
||||
<Button variant="primary" onClick={handleApplyClick}>
|
||||
Apply for this Position
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* --- End Main Content Area --- */}
|
||||
|
||||
{/* --- Sidebar (Right Column) --- */}
|
||||
<div className="lg:col-span-1 space-y-8">
|
||||
{/* Metadata Card - Exactly as before */}
|
||||
<div className="bg-gray-50 shadow-md rounded-lg p-6 border-l-4 border-primary">
|
||||
<h3 className="text-xl font-semibold mb-5 text-gray-900 font-poppins">
|
||||
Job Overview
|
||||
</h3>
|
||||
<div className="space-y-4">
|
||||
<MetadataItem
|
||||
icon={FaBuilding}
|
||||
label="Department"
|
||||
value={vacancy.department}
|
||||
/>
|
||||
<MetadataItem
|
||||
icon={FaMapMarkerAlt}
|
||||
label="Location"
|
||||
value={
|
||||
<>
|
||||
{vacancy.location.city}, {vacancy.location.country}{" "}
|
||||
{vacancy.location.remote && <Badge>Remote Possible</Badge>}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<MetadataItem
|
||||
icon={FaBriefcase}
|
||||
label="Type"
|
||||
value={<Badge>{vacancy.employmentType}</Badge>}
|
||||
/>
|
||||
<MetadataItem
|
||||
icon={FaGraduationCap}
|
||||
label="Level"
|
||||
value={vacancy.experienceLevel}
|
||||
/>
|
||||
{vacancy.salary && (
|
||||
<MetadataItem
|
||||
icon={FaDollarSign}
|
||||
label="Salary"
|
||||
value={
|
||||
<span className="font-semibold text-gray-800">
|
||||
{vacancy.salary.min.toLocaleString()} -{" "}
|
||||
{vacancy.salary.max.toLocaleString()}{" "}
|
||||
{vacancy.salary.currency} {vacancy.salary.period}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<MetadataItem
|
||||
icon={FaCalendarAlt}
|
||||
label="Posted"
|
||||
value={formatDate(vacancy.postedDate)}
|
||||
/>
|
||||
{vacancy.applicationDeadline && (
|
||||
<MetadataItem
|
||||
icon={FaClock}
|
||||
label="Apply by"
|
||||
value={formatDate(vacancy.applicationDeadline)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Share Card - Exactly as before */}
|
||||
<div className="bg-gray-50 shadow-md rounded-lg p-6 border-l-4 border-primary">
|
||||
<h3 className="text-xl font-semibold mb-4 text-gray-900 flex items-center gap-2 font-poppins">
|
||||
<FaShareAlt
|
||||
className="h-5 w-5 text-primary"
|
||||
style={{ color: COLORS.primary }}
|
||||
/>{" "}
|
||||
Share this opening
|
||||
</h3>
|
||||
<div className="flex space-x-3 text-sm font-poppins">
|
||||
<a
|
||||
href={`https://www.linkedin.com/sharing/share-offsite/?url=${encodeURIComponent(
|
||||
shareUrl
|
||||
)}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 hover:underline"
|
||||
>
|
||||
LinkedIn
|
||||
</a>
|
||||
<a
|
||||
href={`https://twitter.com/intent/tweet?url=${encodeURIComponent(
|
||||
shareUrl
|
||||
)}&text=${shareTitle}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 hover:underline"
|
||||
>
|
||||
Twitter
|
||||
</a>
|
||||
<a
|
||||
href={`mailto:?subject=${shareTitle}&body=Check out this job opening: ${encodeURIComponent(
|
||||
shareUrl
|
||||
)}`}
|
||||
className="text-gray-600 hover:underline"
|
||||
>
|
||||
Email
|
||||
</a>
|
||||
</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>
|
||||
{/* --- End Sidebar --- */}
|
||||
</div>
|
||||
{/* --- End Main Grid --- */}
|
||||
</div> // End Container
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user