mirror of
https://github.com/OwethuManagedServices/oms-website-nextjs.git
synced 2025-12-17 13:58:09 +00:00
Vacancy added
This commit is contained in:
74
actions/apply.ts
Normal file
74
actions/apply.ts
Normal 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!" };
|
||||
}
|
||||
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
|
||||
);
|
||||
}
|
||||
19
app/api/vacancies/[slug]/route.ts
Normal file
19
app/api/vacancies/[slug]/route.ts
Normal 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);
|
||||
}
|
||||
9
app/api/vacancies/route.ts
Normal file
9
app/api/vacancies/route.ts
Normal 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);
|
||||
}
|
||||
302
components/VacancyApplicationForm.tsx
Normal file
302
components/VacancyApplicationForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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 = {
|
||||
|
||||
19
components/ui/CustomButton.tsx
Normal file
19
components/ui/CustomButton.tsx
Normal 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 };
|
||||
19
components/ui/CustomInput.tsx
Normal file
19
components/ui/CustomInput.tsx
Normal 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 };
|
||||
18
components/ui/CustomLabel.tsx
Normal file
18
components/ui/CustomLabel.tsx
Normal 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 };
|
||||
19
components/ui/CustomTextarea.tsx
Normal file
19
components/ui/CustomTextarea.tsx
Normal 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
9
constants/index.ts
Normal 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
163
lib/demo-data/vacancies.ts
Normal 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.",
|
||||
"Bachelor’s 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
13
lib/helpers.ts
Normal 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
36
package-lock.json
generated
@ -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",
|
||||
|
||||
@ -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
38
types/index.d.ts
vendored
@ -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";
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user