diff --git a/actions/apply.ts b/actions/apply.ts new file mode 100644 index 0000000..e655c50 --- /dev/null +++ b/actions/apply.ts @@ -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; + +interface ActionResult { + success: boolean; + message?: string; +} + +export async function submitApplication( + formData: ApplicationFormData +): Promise { + // 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!" }; +} diff --git a/app/(website)/vacancies/[slug]/page.tsx b/app/(website)/vacancies/[slug]/page.tsx new file mode 100644 index 0000000..58a3509 --- /dev/null +++ b/app/(website)/vacancies/[slug]/page.tsx @@ -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 { + 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 ( +
+ +
+ ); +} diff --git a/app/(website)/vacancies/_components/Badge.tsx b/app/(website)/vacancies/_components/Badge.tsx new file mode 100644 index 0000000..64a1393 --- /dev/null +++ b/app/(website)/vacancies/_components/Badge.tsx @@ -0,0 +1,12 @@ +export const Badge = ({ + children, + icon: Icon, +}: { + children: React.ReactNode; + icon?: React.ElementType; +}) => ( + + {Icon && } + {children} + +); diff --git a/app/(website)/vacancies/_components/ListSection.tsx b/app/(website)/vacancies/_components/ListSection.tsx new file mode 100644 index 0000000..26cc056 --- /dev/null +++ b/app/(website)/vacancies/_components/ListSection.tsx @@ -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 ( +
+

+ {Icon && ( + + )} + {title} +

+
    + {items.map((item, index) => ( +
  • {item}
  • + ))} +
+
+ ); +}; diff --git a/app/(website)/vacancies/_components/MetadataItem.tsx b/app/(website)/vacancies/_components/MetadataItem.tsx new file mode 100644 index 0000000..f5eeaba --- /dev/null +++ b/app/(website)/vacancies/_components/MetadataItem.tsx @@ -0,0 +1,22 @@ +import { COLORS } from "@/constants"; + +export const MetadataItem = ({ + icon: Icon, + label, + value, +}: { + icon: React.ElementType; + label: string; + value: React.ReactNode; +}) => ( +
+
+); diff --git a/app/(website)/vacancies/_components/VacancyClientContent.tsx b/app/(website)/vacancies/_components/VacancyClientContent.tsx new file mode 100644 index 0000000..7d60662 --- /dev/null +++ b/app/(website)/vacancies/_components/VacancyClientContent.tsx @@ -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(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 +
+ {/* --- Re-include the FULL Company Header --- */} + {vacancy.company && ( +
+
+ {vacancy.company.logoUrl ? ( + {`${vacancy.company.name} + ) : ( +
+ +
+ )} +
+

+ {vacancy.title} +

+

at {vacancy.company.name}

+ {vacancy.company.websiteUrl && ( + + Visit website{" "} + + + )} +
+
+ {/* Apply Button in Header (conditional) */} + {!showApplyForm && ( + + )} +
+ )} + {/* --- End Company Header --- */} + + {/* --- Main Grid Layout --- */} +
+ {/* --- Main Content Area (Left Column) --- */} +
+ {/* Title if no company header */} + {!vacancy.company && ( +

+ {vacancy.title} +

+ )} + + {/* Job Description */} +
+

+ {" "} + Job Description +

+
+

{vacancy.description}

+
+
+ + {/* --- All List Sections (Responsibilities, Qualifications, Skills, Benefits) --- */} + + + + + {/* Skills Section - RE-INCLUDED */} + {vacancy.skills && vacancy.skills.length > 0 && ( +
+

+ {" "} + Skills +

+
+ {vacancy.skills.map((skill, index) => ( + {skill} + ))} +
+
+ )} + + {/* Benefits Section - RE-INCLUDED */} + {vacancy.benefits && vacancy.benefits.length > 0 && ( +
+

+ {" "} + Benefits +

+
    + {vacancy.benefits.map((item, index) => ( +
  • + + {item} +
  • + ))} +
+
+ )} + {/* --- End List Sections --- */} + + {/* Apply button below main content (conditional) */} + {!showApplyForm && ( +
+ +
+ )} +
+ {/* --- End Main Content Area --- */} + + {/* --- Sidebar (Right Column) --- */} +
+ {/* Metadata Card - Exactly as before */} +
+

+ Job Overview +

+
+ + + {vacancy.location.city}, {vacancy.location.country}{" "} + {vacancy.location.remote && Remote Possible} + + } + /> + {vacancy.employmentType}} + /> + + {vacancy.salary && ( + + {vacancy.salary.min.toLocaleString()} -{" "} + {vacancy.salary.max.toLocaleString()}{" "} + {vacancy.salary.currency} {vacancy.salary.period} + + } + /> + )} + + {vacancy.applicationDeadline && ( + + )} +
+
+ + {/* Share Card - Exactly as before */} +
+

+ {" "} + Share this opening +

+ +
+ + {/* --- Conditionally Rendered Application Form Section --- */} + {showApplyForm && ( +
+ {" "} + {/* scroll-mt helps anchor links, lg:mt-0 aligns with other cards */} +

+ Apply for: {vacancy.title} +

+ setShowApplyForm(false)} + /> +
+ )} + {/* --- End Form Section --- */} +
+ {/* --- End Sidebar --- */} +
+ {/* --- End Main Grid --- */} +
// End Container + ); +} diff --git a/app/api/vacancies/[slug]/route.ts b/app/api/vacancies/[slug]/route.ts new file mode 100644 index 0000000..5cbc86a --- /dev/null +++ b/app/api/vacancies/[slug]/route.ts @@ -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); +} diff --git a/app/api/vacancies/route.ts b/app/api/vacancies/route.ts new file mode 100644 index 0000000..bb8e917 --- /dev/null +++ b/app/api/vacancies/route.ts @@ -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); +} diff --git a/components/VacancyApplicationForm.tsx b/components/VacancyApplicationForm.tsx new file mode 100644 index 0000000..40bdc91 --- /dev/null +++ b/components/VacancyApplicationForm.tsx @@ -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(null); + const [isDragging, setIsDragging] = useState(false); + const [isSubmitting, setIsSubmitting] = useState(false); + const [submitStatus, setSubmitStatus] = useState< + "idle" | "success" | "error" + >("idle"); + + const handleInputChange = ( + e: ChangeEvent + ) => { + const { name, value } = e.target; + setFormData((prev) => ({ ...prev, [name]: value })); + }; + + // --- File Input Handling --- + const handleFileChange = (e: ChangeEvent) => { + 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) => { + e.preventDefault(); + e.stopPropagation(); + setIsDragging(true); + }, []); + + const handleDragLeave = useCallback((e: DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setIsDragging(false); + }, []); + + const handleDragOver = useCallback( + (e: DragEvent) => { + e.preventDefault(); // Necessary to allow drop + e.stopPropagation(); + if (!isDragging) setIsDragging(true); // Ensure state is true while over + }, + [isDragging] + ); + + const handleDrop = useCallback((e: DragEvent) => { + 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) => { + 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 ( +
+ {/* Example Fields */} +
+ + +
+
+ + +
+
+ + +
+
+ + +
+ + {/* --- Styled File Upload Area --- */} +
+ +
document.getElementById("resume-upload")?.click()} // Trigger hidden input + > + {/* Hidden Actual Input */} + + + {/* Visual Cue */} + +

+ {isDragging ? "Drop file here" : "Drag & drop your file here"} +

+

+ or click to browse +

+

(PDF, DOC, DOCX, TXT)

+
+ + {/* Display Selected File */} + {resumeFile && ( +
+
+ + + {resumeFile.name} + + + ({(resumeFile.size / 1024).toFixed(1)} KB) + +
+ +
+ )} +
+ {/* --- End File Upload Area --- */} + +
+ + +
+ + {/* Submit Button & Status */} +
+ + + {submitStatus === "success" && ( +

+ Application submitted successfully! +

+ )} + {submitStatus === "error" && ( +

+ Submission failed. Please try again. +

+ )} +
+
+ ); +} diff --git a/components/ui/Button.tsx b/components/ui/Button.tsx index d7ad96c..85b744b 100644 --- a/components/ui/Button.tsx +++ b/components/ui/Button.tsx @@ -20,7 +20,7 @@ const Button: React.FC = ({ }) => { // 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 = { diff --git a/components/ui/CustomButton.tsx b/components/ui/CustomButton.tsx new file mode 100644 index 0000000..d65f3ed --- /dev/null +++ b/components/ui/CustomButton.tsx @@ -0,0 +1,19 @@ +import React from "react"; + +const CustomButton = React.forwardRef< + HTMLButtonElement, + React.ButtonHTMLAttributes +>(({ className, children, ...props }, ref) => { + return ( + + ); +}); +CustomButton.displayName = "CustomButton"; + +export { CustomButton }; diff --git a/components/ui/CustomInput.tsx b/components/ui/CustomInput.tsx new file mode 100644 index 0000000..0f26dc0 --- /dev/null +++ b/components/ui/CustomInput.tsx @@ -0,0 +1,19 @@ +import React from "react"; + +type CustomInputProps = React.InputHTMLAttributes; + +const CustomInput = React.forwardRef( + ({ className, type, ...props }, ref) => { + return ( + + ); + } +); +CustomInput.displayName = "CustomInput"; + +export { CustomInput }; diff --git a/components/ui/CustomLabel.tsx b/components/ui/CustomLabel.tsx new file mode 100644 index 0000000..d7984c6 --- /dev/null +++ b/components/ui/CustomLabel.tsx @@ -0,0 +1,18 @@ +import React from "react"; + +type CustomLabelProps = React.LabelHTMLAttributes; + +const CustomLabel = React.forwardRef( + ({ className, ...props }, ref) => { + return ( +