Vacancy added

This commit is contained in:
libertyoms
2025-04-27 08:51:09 +02:00
parent 809f5c6ff7
commit 1be00d7a42
20 changed files with 1203 additions and 1 deletions

74
actions/apply.ts Normal file
View File

@ -0,0 +1,74 @@
"use server";
import * as z from "zod";
// Re-define or import the schema to validate on the server-side
// Ensure this matches the client-side schema
const applicationSchema = z.object({
vacancyId: z.string(),
vacancyTitle: z.string(),
firstName: z.string().min(1, "First name is required"),
lastName: z.string().min(1, "Last name is required"),
email: z.string().email("Invalid email address"),
phone: z.string().optional(),
linkedinUrl: z.string().url("Invalid URL").optional().or(z.literal("")),
portfolioUrl: z.string().url("Invalid URL").optional().or(z.literal("")),
coverLetter: z.string().optional(),
// Note: File uploads (resume) are not handled in this basic action.
// Handling files requires FormData and different processing.
});
type ApplicationFormData = z.infer<typeof applicationSchema>;
interface ActionResult {
success: boolean;
message?: string;
}
export async function submitApplication(
formData: ApplicationFormData
): Promise<ActionResult> {
// 1. Validate data on the server
const validatedFields = applicationSchema.safeParse(formData);
if (!validatedFields.success) {
console.error(
"Server-side validation failed:",
validatedFields.error.flatten().fieldErrors
);
return {
success: false,
message: "Invalid data provided. Please check the form.",
// Optionally return specific field errors: errors: validatedFields.error.flatten().fieldErrors
};
}
const applicationData = validatedFields.data;
// 2. Process the application (e.g., save to database, send email)
// For this demo, we'll just log the data.
console.log(
"Received application:",
JSON.stringify(applicationData, null, 2)
);
// Simulate processing time
await new Promise((resolve) => setTimeout(resolve, 1000));
// In a real application:
// - Save applicationData to your database (e.g., using Prisma or Directus SDK)
// - Handle resume file upload (requires FormData, potentially upload to storage like S3/Minio)
// - Send notification emails (to HR, to the applicant)
// Example of error handling during processing:
// try {
// await saveApplicationToDatabase(applicationData);
// await sendConfirmationEmail(applicationData.email);
// } catch (error) {
// console.error('Failed to process application:', error);
// return { success: false, message: 'Failed to save application.' };
// }
// 3. Return success response
return { success: true, message: "Application submitted successfully!" };
}

View 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>
);
}

View 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>
);

View 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>
);
};

View 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>
);

View 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
);
}

View File

@ -0,0 +1,19 @@
import { NextResponse } from "next/server";
import { demoVacancies } from "@/lib/demo-data/vacancies";
export async function GET(
request: Request,
{ params }: { params: { slug: string } }
) {
const slug = params.slug;
// In a real application, you would fetch this data from your CMS (Directus)
const vacancy = demoVacancies.find(
(v) => v.slug === slug && v.status === "Open"
);
if (!vacancy) {
return NextResponse.json({ message: "Vacancy not found" }, { status: 404 });
}
return NextResponse.json(vacancy);
}

View File

@ -0,0 +1,9 @@
import { NextResponse } from "next/server";
import { demoVacancies } from "@/lib/demo-data/vacancies";
export async function GET() {
// In a real application, you would fetch this data from your CMS (Directus)
// For now, we use the demo data
const openVacancies = demoVacancies.filter((v) => v.status === "Open");
return NextResponse.json(openVacancies);
}

View File

@ -0,0 +1,302 @@
// src/components/VacancyApplicationForm.tsx (Example structure and styling)
"use client"; // This likely needs to be a client component for form handling
import React, { useState, useCallback, ChangeEvent, DragEvent } from "react";
import { FaUpload, FaFileAlt, FaTimes } from "react-icons/fa"; // Import icons
interface VacancyApplicationFormProps {
vacancyId: string;
vacancyTitle: string;
// onFormClose?: () => void; // Optional: If you add a close button
}
export default function VacancyApplicationForm({
vacancyId,
vacancyTitle,
}: VacancyApplicationFormProps) {
const [formData, setFormData] = useState({
name: "",
email: "",
phone: "",
linkedin: "",
coverLetter: "",
// Add other fields as needed
});
const [resumeFile, setResumeFile] = useState<File | null>(null);
const [isDragging, setIsDragging] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const [submitStatus, setSubmitStatus] = useState<
"idle" | "success" | "error"
>("idle");
const handleInputChange = (
e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>
) => {
const { name, value } = e.target;
setFormData((prev) => ({ ...prev, [name]: value }));
};
// --- File Input Handling ---
const handleFileChange = (e: ChangeEvent<HTMLInputElement>) => {
if (e.target.files && e.target.files.length > 0) {
setResumeFile(e.target.files[0]);
e.target.value = ""; // Reset input value for potential re-upload
}
};
const handleDragEnter = useCallback((e: DragEvent<HTMLDivElement>) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(true);
}, []);
const handleDragLeave = useCallback((e: DragEvent<HTMLDivElement>) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
}, []);
const handleDragOver = useCallback(
(e: DragEvent<HTMLDivElement>) => {
e.preventDefault(); // Necessary to allow drop
e.stopPropagation();
if (!isDragging) setIsDragging(true); // Ensure state is true while over
},
[isDragging]
);
const handleDrop = useCallback((e: DragEvent<HTMLDivElement>) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
if (e.dataTransfer.files && e.dataTransfer.files.length > 0) {
setResumeFile(e.dataTransfer.files[0]);
e.dataTransfer.clearData();
}
}, []);
const removeFile = () => {
setResumeFile(null);
};
// --- End File Input Handling ---
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
setIsSubmitting(true);
setSubmitStatus("idle");
const data = new FormData();
data.append("vacancyId", vacancyId);
data.append("vacancyTitle", vacancyTitle);
data.append("name", formData.name);
data.append("email", formData.email);
data.append("phone", formData.phone);
data.append("linkedin", formData.linkedin);
data.append("coverLetter", formData.coverLetter);
if (resumeFile) {
data.append("resume", resumeFile, resumeFile.name);
}
try {
// Replace with your actual API endpoint for form submission
const response = await fetch("/api/apply", {
method: "POST",
body: data, // FormData handles multipart/form-data automatically
});
if (!response.ok) {
throw new Error("Submission failed");
}
setSubmitStatus("success");
// Optionally reset form:
// setFormData({ name: '', email: '', ... });
// setResumeFile(null);
// Optionally close form: onFormClose?.();
} catch (error) {
console.error("Application submission error:", error);
setSubmitStatus("error");
} finally {
setIsSubmitting(false);
}
};
// --- Base Input Styling ---
const inputBaseStyle =
"block w-full px-4 py-2 mt-1 text-gray-700 bg-white border border-gray-300 rounded-md focus:border-gold-500 focus:ring focus:ring-gold-500 focus:ring-opacity-50 dark:bg-gray-800 dark:text-gray-300 dark:border-gray-600 dark:focus:border-gold-500 font-poppins";
return (
<form onSubmit={handleSubmit} className="space-y-6 font-poppins">
{/* Example Fields */}
<div>
<label htmlFor="name" className="text-gray-700 dark:text-gray-200">
Full Name *
</label>
<input
id="name"
name="name"
type="text"
required
className={inputBaseStyle}
value={formData.name}
onChange={handleInputChange}
/>
</div>
<div>
<label htmlFor="email" className="text-gray-700 dark:text-gray-200">
Email Address *
</label>
<input
id="email"
name="email"
type="email"
required
className={inputBaseStyle}
value={formData.email}
onChange={handleInputChange}
/>
</div>
<div>
<label htmlFor="phone" className="text-gray-700 dark:text-gray-200">
Phone Number
</label>
<input
id="phone"
name="phone"
type="tel"
className={inputBaseStyle}
value={formData.phone}
onChange={handleInputChange}
/>
</div>
<div>
<label htmlFor="linkedin" className="text-gray-700 dark:text-gray-200">
LinkedIn Profile URL
</label>
<input
id="linkedin"
name="linkedin"
type="url"
className={inputBaseStyle}
value={formData.linkedin}
onChange={handleInputChange}
placeholder="https://linkedin.com/in/yourprofile"
/>
</div>
{/* --- Styled File Upload Area --- */}
<div>
<label className="text-gray-700 dark:text-gray-200 block mb-2">
Resume/CV *
</label>
<div
className={`relative flex flex-col items-center justify-center w-full p-6 transition-colors duration-300 ease-in-out border-2 border-dashed rounded-lg cursor-pointer hover:border-gold-500/80 ${
isDragging
? "border-gold-500 bg-gold-50"
: "border-gray-300 hover:bg-gray-50"
}`}
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
onDragOver={handleDragOver}
onDrop={handleDrop}
onClick={() => document.getElementById("resume-upload")?.click()} // Trigger hidden input
>
{/* Hidden Actual Input */}
<input
id="resume-upload"
name="resume"
type="file"
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
onChange={handleFileChange}
accept=".pdf,.doc,.docx,.txt" // Specify accepted file types
aria-hidden="true" // Hide from accessibility tree as visual element handles interaction
/>
{/* Visual Cue */}
<FaUpload
className={`w-8 h-8 mb-3 ${
isDragging ? "text-gold-600" : "text-gray-400"
}`}
style={{ color: isDragging ? "#c8a93d" : undefined }}
/>
<p
className={`text-sm font-semibold text-center ${
isDragging ? "text-gold-700" : "text-gray-600"
}`}
>
{isDragging ? "Drop file here" : "Drag & drop your file here"}
</p>
<p className="text-xs text-gray-500 text-center">
or click to browse
</p>
<p className="mt-2 text-xs text-gray-500">(PDF, DOC, DOCX, TXT)</p>
</div>
{/* Display Selected File */}
{resumeFile && (
<div className="mt-3 flex items-center justify-between px-3 py-2 text-sm text-gray-700 bg-gray-100 border border-gray-200 rounded-md">
<div className="flex items-center gap-2">
<FaFileAlt className="w-4 h-4 text-gray-500" />
<span className="font-medium truncate max-w-[200px] sm:max-w-xs">
{resumeFile.name}
</span>
<span className="text-xs text-gray-500">
({(resumeFile.size / 1024).toFixed(1)} KB)
</span>
</div>
<button
type="button"
onClick={removeFile}
className="text-red-500 hover:text-red-700 focus:outline-none"
aria-label="Remove file"
>
<FaTimes className="w-4 h-4" />
</button>
</div>
)}
</div>
{/* --- End File Upload Area --- */}
<div>
<label
htmlFor="coverLetter"
className="text-gray-700 dark:text-gray-200"
>
Cover Letter (Optional)
</label>
<textarea
id="coverLetter"
name="coverLetter"
rows={4}
className={`${inputBaseStyle} resize-vertical`}
value={formData.coverLetter}
onChange={handleInputChange}
></textarea>
</div>
{/* Submit Button & Status */}
<div className="flex flex-col items-center space-y-3 pt-4">
<button
type="submit"
disabled={
isSubmitting || !formData.name || !formData.email || !resumeFile
} // Basic validation example
className="w-full md:w-auto px-8 py-3 font-bold text-white bg-gray-800 rounded-md hover:bg-gray-900 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-gray-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors duration-300"
>
{isSubmitting ? "Submitting..." : "Submit Application"}
</button>
{submitStatus === "success" && (
<p className="text-sm text-green-600">
Application submitted successfully!
</p>
)}
{submitStatus === "error" && (
<p className="text-sm text-red-600">
Submission failed. Please try again.
</p>
)}
</div>
</form>
);
}

View File

@ -20,7 +20,7 @@ const Button: React.FC<ButtonProps> = ({
}) => {
// Base styles including focus ring using semantic vars
const baseStyle =
"inline-flex items-center justify-center rounded-lg font-semibold transition-colors duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background";
"inline-flex items-center justify-center rounded-lg font-semibold transition-colors duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background cursor-pointer disabled:pointer-events-none disabled:opacity-50 text-sm";
// Variant styles using semantic vars (Tailwind classes generated via @theme)
const variantStyles = {

View File

@ -0,0 +1,19 @@
import React from "react";
const CustomButton = React.forwardRef<
HTMLButtonElement,
React.ButtonHTMLAttributes<HTMLButtonElement>
>(({ className, children, ...props }, ref) => {
return (
<button
className={`inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 bg-primary text-primary-foreground hover:bg-primary/90 h-10 px-4 py-2 ${className}`}
ref={ref}
{...props}
>
{children}
</button>
);
});
CustomButton.displayName = "CustomButton";
export { CustomButton };

View File

@ -0,0 +1,19 @@
import React from "react";
type CustomInputProps = React.InputHTMLAttributes<HTMLInputElement>;
const CustomInput = React.forwardRef<HTMLInputElement, CustomInputProps>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={`flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 ${className}`}
ref={ref}
{...props}
/>
);
}
);
CustomInput.displayName = "CustomInput";
export { CustomInput };

View File

@ -0,0 +1,18 @@
import React from "react";
type CustomLabelProps = React.LabelHTMLAttributes<HTMLLabelElement>;
const CustomLabel = React.forwardRef<HTMLLabelElement, CustomLabelProps>(
({ className, ...props }, ref) => {
return (
<label
ref={ref}
className={`text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 ${className}`}
{...props}
/>
);
}
);
CustomLabel.displayName = "CustomLabel";
export { CustomLabel };

View File

@ -0,0 +1,19 @@
import React from "react";
type CustomTextareaProps = React.TextareaHTMLAttributes<HTMLTextAreaElement>;
const CustomTextarea = React.forwardRef<
HTMLTextAreaElement,
CustomTextareaProps
>(({ className, ...props }, ref) => {
return (
<textarea
className={`flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 ${className}`}
ref={ref}
{...props}
/>
);
});
CustomTextarea.displayName = "CustomTextarea";
export { CustomTextarea };

9
constants/index.ts Normal file
View File

@ -0,0 +1,9 @@
export const COLORS = {
primary: "#e1c44a",
secondary: "#f50057",
accent: "#ff5722",
success: "#4caf50",
warning: "#ff9800",
error: "#f44336",
info: "#2196f3",
};

163
lib/demo-data/vacancies.ts Normal file
View File

@ -0,0 +1,163 @@
import { Vacancy } from "@/types";
export const demoVacancies: Vacancy[] = [
{
id: "1",
title: "Senior Frontend Engineer",
slug: "senior-frontend-engineer",
description:
"We are looking for a talented Senior Frontend Engineer to join our growing team. You will be responsible for building and maintaining our user-facing web applications, ensuring high performance and responsiveness.",
department: "Engineering",
location: {
city: "Amsterdam",
country: "Netherlands",
remote: false,
},
employmentType: "Full-time",
experienceLevel: "Senior-level",
salary: {
min: 70000,
max: 90000,
currency: "EUR",
period: "Annual",
},
responsibilities: [
"Develop new user-facing features using React and TypeScript.",
"Build reusable code and libraries for future use.",
"Ensure the technical feasibility of UI/UX designs.",
"Optimize application for maximum speed and scalability.",
"Collaborate with other team members and stakeholders.",
"Mentor junior engineers.",
],
requiredQualifications: [
"5+ years of experience in frontend development.",
"Proficient understanding of web markup, including HTML5, CSS3.",
"Strong proficiency in JavaScript, including DOM manipulation and the JavaScript object model.",
"Thorough understanding of React.js and its core principles.",
"Experience with popular React.js workflows (such as Flux or Redux).",
"Familiarity with newer specifications of EcmaScript.",
"Experience with data structure libraries (e.g., Immutable.js).",
"Knowledge of modern authorization mechanisms, such as JSON Web Token.",
"Familiarity with modern front-end build pipelines and tools.",
"Experience with common front-end development tools such as Babel, Webpack, NPM, etc.",
"Ability to understand business requirements and translate them into technical requirements.",
"A knack for benchmarking and optimization.",
"Familiarity with code versioning tools, such as Git.",
],
preferredQualifications: [
"Experience with Next.js.",
"Experience with TypeScript.",
"Experience with testing frameworks like Jest or React Testing Library.",
"Familiarity with GraphQL.",
"Experience working in an Agile/Scrum development process.",
],
benefits: [
"Competitive salary and benefits package.",
"Opportunity to work on challenging projects.",
"Collaborative and supportive work environment.",
"Generous vacation policy.",
"Professional development opportunities.",
],
applicationDeadline: "2025-06-15T23:59:59Z",
postedDate: "2025-04-20T09:00:00Z",
contactPerson: {
name: "Jane Doe",
email: "jane.doe@example.com",
},
status: "Open",
},
{
id: "2",
title: "Product Marketing Manager",
slug: "product-marketing-manager",
description:
"Seeking an experienced Product Marketing Manager to lead the go-to-market strategy for our innovative products. You will craft compelling messaging and positioning, drive demand, and enable sales.",
department: "Marketing",
location: {
city: "London",
country: "UK",
remote: true,
},
employmentType: "Full-time",
experienceLevel: "Mid-level",
salary: {
min: 60000,
max: 80000,
currency: "GBP",
period: "Annual",
},
responsibilities: [
"Develop product positioning and messaging that differentiates our products in the market.",
"Sales enablement communicate the value proposition of the products to the sales team and develop the sales tools that support the selling process.",
"Product launch plan the launch of new products and releases and manage the cross-functional implementation of the plan.",
"Market intelligence be the expert on buyers, how they buy and their buying criteria; be the expert on competition.",
"Demand generation develop the strategy and manage the marketing programs that drive demand for your products.",
],
requiredQualifications: [
"3+ years of product marketing experience with B2B SaaS products.",
"Proven track record of successfully launching and marketing products.",
"Excellent written and verbal communication skills.",
"Strong analytical and project management skills.",
"Bachelors degree in Marketing, Business, or related field.",
],
preferredQualifications: [
"Experience in the tech industry.",
"Familiarity with marketing automation tools (e.g., HubSpot, Marketo).",
"MBA is a plus.",
],
benefits: [
"Competitive compensation.",
"Health, dental, and vision insurance.",
"Flexible working hours and remote work options.",
"Stock options.",
],
postedDate: "2025-04-25T10:00:00Z",
status: "Open",
},
{
id: "3",
title: "Backend Developer (Node.js)",
slug: "backend-developer-nodejs",
description:
"Join our backend team to design, develop, and maintain server-side logic, databases, and APIs for our platform. Focus on scalability, performance, and reliability.",
department: "Engineering",
location: {
city: "Berlin",
country: "Germany",
remote: false,
},
employmentType: "Full-time",
experienceLevel: "Mid-level",
responsibilities: [
"Design and implement low-latency, high-availability, and performant applications.",
"Write reusable, testable, and efficient code.",
"Integration of user-facing elements developed by front-end developers with server side logic.",
"Implementation of security and data protection.",
"Integration of data storage solutions (may include databases, key-value stores, blob stores, etc.).",
],
requiredQualifications: [
"3+ years of experience as a Backend Developer.",
"Strong proficiency with Node.js and JavaScript/TypeScript.",
"Experience with frameworks such as Express, Koa, or NestJS.",
"Understanding the nature of asynchronous programming and its quirks and workarounds.",
"Experience with RESTful APIs design and implementation.",
"Knowledge of database technologies (SQL and NoSQL).",
"Understanding fundamental design principles behind a scalable application.",
"Proficient understanding of code versioning tools, such as Git.",
],
preferredQualifications: [
"Experience with microservices architecture.",
"Familiarity with Docker and Kubernetes.",
"Experience with cloud platforms like AWS, Azure, or GCP.",
"Knowledge of message queue systems (e.g., RabbitMQ, Kafka).",
],
benefits: [
"Opportunity to work with modern technologies.",
"Dynamic and international team.",
"Regular team events.",
"Public transport ticket.",
],
postedDate: "2025-04-27T08:30:00Z",
status: "Open",
},
];

13
lib/helpers.ts Normal file
View File

@ -0,0 +1,13 @@
export const formatDate = (dateString?: string): string => {
if (!dateString) return "N/A";
try {
return new Date(dateString).toLocaleDateString("en-ZA", {
year: "numeric",
month: "long",
day: "numeric",
});
} catch (e) {
console.error("Invalid date format:", dateString, e);
return "Invalid Date";
}
};

36
package-lock.json generated
View File

@ -11,6 +11,7 @@
"@auth/prisma-adapter": "^2.9.0",
"@directus/sdk": "^18.0.3",
"@google/generative-ai": "^0.24.0",
"@hookform/resolvers": "^5.0.1",
"@prisma/client": "^6.6.0",
"date-fns": "^4.1.0",
"minio": "^8.0.5",
@ -20,6 +21,7 @@
"nodemailer": "^6.10.1",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-hook-form": "^7.56.1",
"react-icons": "^5.5.0",
"react-spinners": "^0.16.1",
"react-toggle-dark-mode": "^1.1.1",
@ -1265,6 +1267,18 @@
"node": ">=18.0.0"
}
},
"node_modules/@hookform/resolvers": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-5.0.1.tgz",
"integrity": "sha512-u/+Jp83luQNx9AdyW2fIPGY6Y7NG68eN2ZW8FOJYL+M0i4s49+refdJdOp/A9n9HFQtQs3HIDHQvX3ZET2o7YA==",
"license": "MIT",
"dependencies": {
"@standard-schema/utils": "^0.3.0"
},
"peerDependencies": {
"react-hook-form": "^7.55.0"
}
},
"node_modules/@humanfs/core": {
"version": "0.19.1",
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
@ -2590,6 +2604,12 @@
"@sinonjs/commons": "^3.0.0"
}
},
"node_modules/@standard-schema/utils": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
"license": "MIT"
},
"node_modules/@swc/counter": {
"version": "0.1.3",
"resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz",
@ -8845,6 +8865,22 @@
"react": "^19.1.0"
}
},
"node_modules/react-hook-form": {
"version": "7.56.1",
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.56.1.tgz",
"integrity": "sha512-qWAVokhSpshhcEuQDSANHx3jiAEFzu2HAaaQIzi/r9FNPm1ioAvuJSD4EuZzWd7Al7nTRKcKPnBKO7sRn+zavQ==",
"license": "MIT",
"engines": {
"node": ">=18.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/react-hook-form"
},
"peerDependencies": {
"react": "^16.8.0 || ^17 || ^18 || ^19"
}
},
"node_modules/react-icons": {
"version": "5.5.0",
"resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.5.0.tgz",

View File

@ -12,6 +12,7 @@
"@auth/prisma-adapter": "^2.9.0",
"@directus/sdk": "^18.0.3",
"@google/generative-ai": "^0.24.0",
"@hookform/resolvers": "^5.0.1",
"@prisma/client": "^6.6.0",
"date-fns": "^4.1.0",
"minio": "^8.0.5",
@ -21,6 +22,7 @@
"nodemailer": "^6.10.1",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-hook-form": "^7.56.1",
"react-icons": "^5.5.0",
"react-spinners": "^0.16.1",
"react-toggle-dark-mode": "^1.1.1",

38
types/index.d.ts vendored
View File

@ -77,3 +77,41 @@ interface HeroButton {
interface ItemsQuery {
fields?: string[];
}
export interface Vacancy {
id: string;
title: string;
slug: string;
description: string;
department: string;
location: {
city: string;
country: string;
remote: boolean;
};
employmentType: "Full-time" | "Part-time" | "Contract" | "Internship";
experienceLevel:
| "Entry-level"
| "Mid-level"
| "Senior-level"
| "Lead"
| "Manager";
salary?: {
min: number;
max: number;
currency: string;
period: "Annual" | "Monthly" | "Hourly";
};
responsibilities: string[];
requiredQualifications: string[];
preferredQualifications?: string[];
benefits?: string[];
applicationDeadline?: string; // ISO 8601 date string
postedDate: string; // ISO 8601 date string
contactPerson?: {
name: string;
email: string;
phone?: string;
};
status: "Open" | "Closed" | "Filled";
}