From b97c0cf8727ed4cc450637844fd38a90cdb4a817 Mon Sep 17 00:00:00 2001 From: Reeshal-sandra Date: Tue, 1 Sep 2026 19:55:53 +0530 Subject: [PATCH 1/2] feat: implement job details page, error boundary, and loading skeletons --- apps/web/app/components/job/JobDetails.tsx | 46 +++++++ apps/web/app/components/job/JobHeader.tsx | 109 ++++++++++++++++ apps/web/app/components/job/JobSidebar.tsx | 62 +++++++++ apps/web/app/components/layout/AppLayout.tsx | 130 +++++++++++++++++++ apps/web/app/jobs/[id]/error.tsx | 39 ++++++ apps/web/app/jobs/[id]/get-job.ts | 17 +++ apps/web/app/jobs/[id]/layout.tsx | 44 +++++++ apps/web/app/jobs/[id]/loading.tsx | 25 ++++ apps/web/app/jobs/[id]/page.tsx | 114 ++++++++++++++++ apps/web/app/types/job.ts | 21 +++ 10 files changed, 607 insertions(+) create mode 100644 apps/web/app/components/job/JobDetails.tsx create mode 100644 apps/web/app/components/job/JobHeader.tsx create mode 100644 apps/web/app/components/job/JobSidebar.tsx create mode 100644 apps/web/app/components/layout/AppLayout.tsx create mode 100644 apps/web/app/jobs/[id]/error.tsx create mode 100644 apps/web/app/jobs/[id]/get-job.ts create mode 100644 apps/web/app/jobs/[id]/layout.tsx create mode 100644 apps/web/app/jobs/[id]/loading.tsx create mode 100644 apps/web/app/jobs/[id]/page.tsx create mode 100644 apps/web/app/types/job.ts diff --git a/apps/web/app/components/job/JobDetails.tsx b/apps/web/app/components/job/JobDetails.tsx new file mode 100644 index 0000000..7e7a6ef --- /dev/null +++ b/apps/web/app/components/job/JobDetails.tsx @@ -0,0 +1,46 @@ +import React from 'react' +import { Job } from '../../types/job' + +export const JobDetails = ({ job }: { job: Job }) => { + const aboutRole = job.aboutRole ?? [job.description ?? 'No description provided.'] + const responsibilities = job.responsibilities ?? [] + const qualifications = job.qualifications ?? [] + + return ( +
+ {/* About the Role */} +
+

About the Role

+
+ {aboutRole.map((paragraph: string, idx: number) => ( +

{paragraph}

+ ))} +
+
+ + {/* Key Responsibilities */} + {responsibilities.length > 0 && ( +
+

Key Responsibilities

+
    + {responsibilities.map((item: string, idx: number) => ( +
  • {item}
  • + ))} +
+
+ )} + + {/* Qualifications */} + {qualifications.length > 0 && ( +
+

Qualifications

+
    + {qualifications.map((item: string, idx: number) => ( +
  • {item}
  • + ))} +
+
+ )} +
+ ) +} diff --git a/apps/web/app/components/job/JobHeader.tsx b/apps/web/app/components/job/JobHeader.tsx new file mode 100644 index 0000000..befb4d3 --- /dev/null +++ b/apps/web/app/components/job/JobHeader.tsx @@ -0,0 +1,109 @@ +'use client' + +import React from 'react' +import Link from 'next/link' +import { ArrowLeft, Share2, MapPin, DollarSign, Clock, Bookmark, Flag } from 'lucide-react' +import { Job } from '../../types/job' + +interface JobHeaderProps { + job: Job + isSaved?: boolean + isApplying?: boolean + onApply?: () => void + onSave?: () => void + onReport?: () => void +} + +export const JobHeader = ({ + job, + isSaved = false, + isApplying = false, + onApply, + onSave, + onReport, +}: JobHeaderProps) => { + return ( +
+ {/* Breadcrumb Navigation & Action Controls */} +
+ + + Back to Feed + +
+ + + +
+
+ + {/* Main Role Header */} +
+
+ + {job.category} + +

{job.title}

+ +
+ + + {job.location} + + + + {job.salaryRange} + + + + {job.type} + +
+
+ + {/* Sticky Apply Button wrapper */} +
+ +
+
+
+ ) +} diff --git a/apps/web/app/components/job/JobSidebar.tsx b/apps/web/app/components/job/JobSidebar.tsx new file mode 100644 index 0000000..80ff08f --- /dev/null +++ b/apps/web/app/components/job/JobSidebar.tsx @@ -0,0 +1,62 @@ +import React from 'react' +import { Building2, Users, Calendar, Code2 } from 'lucide-react' +import { Job } from '../../types/job' + +export const JobSidebar = ({ job }: { job: Job }) => { + return ( +
+

+ Company Overview +

+ +
+
+ +
+

Industry

+

+ {job.companyOverview?.industry ?? 'Technology'} +

+
+
+ +
+ +
+

Company Size

+

+ {job.companyOverview?.size ?? '50-200 employees'} +

+
+
+ +
+ +
+

Founded

+

{job.companyOverview?.founded ?? '2020'}

+
+
+ +
+ +
+

Tech Stack

+
+ {(job.companyOverview?.techStack ?? ['TypeScript', 'Next.js', 'Hono']).map( + (tech: string) => ( + + {tech} + + ), + )} +
+
+
+
+
+ ) +} diff --git a/apps/web/app/components/layout/AppLayout.tsx b/apps/web/app/components/layout/AppLayout.tsx new file mode 100644 index 0000000..e6e4110 --- /dev/null +++ b/apps/web/app/components/layout/AppLayout.tsx @@ -0,0 +1,130 @@ +'use client' + +import React from 'react' +import Link from 'next/link' +import { + Briefcase, + LayoutGrid, + FileText, + User, + HelpCircle, + CheckCircle2, + ArrowRight, +} from 'lucide-react' + +interface AppLayoutProps { + children: React.ReactNode +} + +export const AppLayout = ({ children }: AppLayoutProps) => { + return ( +
+ {/* Top Header */} +
+
+
+ +
+ CareerLink +
+ + +
+ + {/* Main Layout Body */} +
+ {/* Left Sidebar */} + + + {/* Content Viewport */} +
+
{children}
+ + {/* Footer */} +
+
+ + © 2026 CAREERLINK PLATFORM. ALL RIGHTS RESERVED. +
+
+ + HELP CENTER + + + PRIVACY POLICY + + + TERMS OF SERVICE + +
+
+
+
+
+ ) +} diff --git a/apps/web/app/jobs/[id]/error.tsx b/apps/web/app/jobs/[id]/error.tsx new file mode 100644 index 0000000..08388e9 --- /dev/null +++ b/apps/web/app/jobs/[id]/error.tsx @@ -0,0 +1,39 @@ +'use client' + +import React, { useEffect } from 'react' +import { AppLayout } from '../../components/layout/AppLayout' +import { AlertTriangle, RefreshCw } from 'lucide-react' + +export default function Error({ + error, + reset, +}: { + error: Error & { digest?: string } + reset: () => void +}) { + useEffect(() => { + console.error('Job page error caught:', error) + }, [error]) + + return ( + +
+
+ +
+

Something went wrong!

+

+ {error.message || + 'Failed to load the job details. Please check your network or try again.'} +

+ +
+
+ ) +} diff --git a/apps/web/app/jobs/[id]/get-job.ts b/apps/web/app/jobs/[id]/get-job.ts new file mode 100644 index 0000000..1150a46 --- /dev/null +++ b/apps/web/app/jobs/[id]/get-job.ts @@ -0,0 +1,17 @@ +import { Job } from '../../types/job' + +const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:8787' + +export async function getJobServer(id: string): Promise { + try { + const res = await fetch(`${API_BASE_URL}/api/jobs/${id}`, { + next: { revalidate: 60 }, // Cache job details for 60 seconds + }) + + if (!res.ok) return null + return (await res.json()) as Job + } catch (error) { + console.error('Failed to fetch job metadata:', error) + return null + } +} diff --git a/apps/web/app/jobs/[id]/layout.tsx b/apps/web/app/jobs/[id]/layout.tsx new file mode 100644 index 0000000..e7e5ca8 --- /dev/null +++ b/apps/web/app/jobs/[id]/layout.tsx @@ -0,0 +1,44 @@ +import { Metadata } from 'next' +import { getJobServer } from './get-job' + +interface Props { + params: Promise<{ id: string }> + children: React.ReactNode +} + +export async function generateMetadata({ params }: Props): Promise { + const { id } = await params + const job = await getJobServer(id) + + if (!job) { + return { + title: 'Job Not Found | Hire Me', + description: 'The requested job posting could not be found.', + } + } + + const title = `${job.title} at ${job.company ?? 'Hire Me'}` + const description = job.description + ? job.description.slice(0, 160) + : `Apply for the ${job.title} position in ${job.location}.` + + return { + title: `${title} | Hire Me`, + description, + openGraph: { + title, + description, + type: 'article', + url: `/jobs/${id}`, + }, + twitter: { + card: 'summary_large_image', + title, + description, + }, + } +} + +export default function JobLayout({ children }: { children: React.ReactNode }) { + return <>{children} +} diff --git a/apps/web/app/jobs/[id]/loading.tsx b/apps/web/app/jobs/[id]/loading.tsx new file mode 100644 index 0000000..332c15b --- /dev/null +++ b/apps/web/app/jobs/[id]/loading.tsx @@ -0,0 +1,25 @@ +// apps/web/app/jobs/[id]/loading.tsx +import { AppLayout } from '../../components/layout/AppLayout' + +export default function Loading() { + return ( + +
+ {/* Header Skeleton */} +
+
+ + {/* Grid Layout Skeleton */} +
+
+
+
+
+
+
+
+
+
+ + ) +} diff --git a/apps/web/app/jobs/[id]/page.tsx b/apps/web/app/jobs/[id]/page.tsx new file mode 100644 index 0000000..6dfbd99 --- /dev/null +++ b/apps/web/app/jobs/[id]/page.tsx @@ -0,0 +1,114 @@ +'use client' + +import React, { useEffect, useState } from 'react' +import { notFound, useParams } from 'next/navigation' +import { AppLayout } from '../../components/layout/AppLayout' +import { JobHeader } from '../../components/job/JobHeader' +import { JobDetails } from '../../components/job/JobDetails' +import { JobSidebar } from '../../components/job/JobSidebar' +import { Job } from '../../types/job' +import { apiFetch, ApiError } from '../../../lib/api-client' + +export default function JobPostingPage() { + const params = useParams() + const id = params.id as string + + const [job, setJob] = useState(null) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + + // 1. STATE VARIABLES GO HERE: + const [isSaved, setIsSaved] = useState(false) + const [isApplying, setIsApplying] = useState(false) + + useEffect(() => { + async function fetchJob() { + try { + setLoading(true) + const data = await apiFetch(`/api/jobs/${id}`) + setJob(data) + } catch (err) { + if (err instanceof ApiError && err.status === 404) { + setJob(null) + } else { + setError(err instanceof Error ? err.message : 'An unexpected error occurred') + } + } finally { + setLoading(false) + } + } + + if (id) { + fetchJob() + } + }, [id]) + + // 2. HANDLER FUNCTIONS GO HERE: + const handleSave = async () => { + try { + setIsSaved((prev) => !prev) + await apiFetch(`/api/jobs/${id}/save`, { method: 'POST' }) + } catch (err) { + setIsSaved((prev) => !prev) + console.error('Failed to save job:', err) + } + } + + const handleApply = async () => { + try { + setIsApplying(true) + await apiFetch(`/api/jobs/${id}/apply`, { method: 'POST' }) + alert('Application submitted successfully!') + } catch (err) { + console.error('Failed to submit application:', err) + alert('Could not submit application. Please try again.') + } finally { + setIsApplying(false) + } + } + + if (loading) { + return ( + +
+
+
+ + ) + } + + if (error) { + return ( + +
+ Failed to load job posting: {error} +
+
+ ) + } + + if (!job) { + notFound() + } + + return ( + + {/* 3. PROPS ARE PASSED TO JOBHEADER HERE IN THE JSX RETURN: */} + +
+
+ +
+
+ +
+
+
+ ) +} diff --git a/apps/web/app/types/job.ts b/apps/web/app/types/job.ts new file mode 100644 index 0000000..405e4e2 --- /dev/null +++ b/apps/web/app/types/job.ts @@ -0,0 +1,21 @@ +export interface CompanyOverview { + industry?: string + size?: string + founded?: string + techStack?: string[] +} + +export interface Job { + id: string + title: string + category: string + location: string + salaryRange: string + type: string + company?: string + description?: string + aboutRole?: string[] + responsibilities?: string[] + qualifications?: string[] + companyOverview?: CompanyOverview +} From dead02f2ac4aea838d6a6b6c34a27e23fa391d24 Mon Sep 17 00:00:00 2001 From: Reeshal-sandra Date: Sun, 13 Sep 2026 23:32:13 +0530 Subject: [PATCH 2/2] refactor(jobs): convert page to async server component and update ui components --- apps/web/app/components/job/JobDetails.tsx | 61 ++++++---- apps/web/app/components/job/JobHeader.tsx | 132 ++++++++++++--------- apps/web/app/components/job/JobSidebar.tsx | 51 ++++---- apps/web/app/jobs/[id]/page.tsx | 106 +++++------------ 4 files changed, 165 insertions(+), 185 deletions(-) diff --git a/apps/web/app/components/job/JobDetails.tsx b/apps/web/app/components/job/JobDetails.tsx index 7e7a6ef..13c9b26 100644 --- a/apps/web/app/components/job/JobDetails.tsx +++ b/apps/web/app/components/job/JobDetails.tsx @@ -1,5 +1,6 @@ import React from 'react' import { Job } from '../../types/job' +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' export const JobDetails = ({ job }: { job: Job }) => { const aboutRole = job.aboutRole ?? [job.description ?? 'No description provided.'] @@ -7,40 +8,48 @@ export const JobDetails = ({ job }: { job: Job }) => { const qualifications = job.qualifications ?? [] return ( -
+ {/* About the Role */} -
-

About the Role

-
- {aboutRole.map((paragraph: string, idx: number) => ( -

{paragraph}

- ))} -
-
+ + About the Role + + + {aboutRole.map((paragraph: string, idx: number) => ( +

{paragraph}

+ ))} +
{/* Key Responsibilities */} {responsibilities.length > 0 && ( -
-

Key Responsibilities

-
    - {responsibilities.map((item: string, idx: number) => ( -
  • {item}
  • - ))} -
-
+ <> + + Key Responsibilities + + +
    + {responsibilities.map((item: string, idx: number) => ( +
  • {item}
  • + ))} +
+
+ )} {/* Qualifications */} {qualifications.length > 0 && ( -
-

Qualifications

-
    - {qualifications.map((item: string, idx: number) => ( -
  • {item}
  • - ))} -
-
+ <> + + Qualifications + + +
    + {qualifications.map((item: string, idx: number) => ( +
  • {item}
  • + ))} +
+
+ )} -
+ ) } diff --git a/apps/web/app/components/job/JobHeader.tsx b/apps/web/app/components/job/JobHeader.tsx index befb4d3..1566030 100644 --- a/apps/web/app/components/job/JobHeader.tsx +++ b/apps/web/app/components/job/JobHeader.tsx @@ -1,107 +1,127 @@ +// components/job/JobHeader.tsx 'use client' -import React from 'react' +import React, { useState } from 'react' import Link from 'next/link' import { ArrowLeft, Share2, MapPin, DollarSign, Clock, Bookmark, Flag } from 'lucide-react' import { Job } from '../../types/job' +import { Button } from '@/components/ui/button' +import { Badge } from '@/components/ui/badge' +import { apiFetch } from '@/lib/api-client' interface JobHeaderProps { job: Job - isSaved?: boolean - isApplying?: boolean - onApply?: () => void - onSave?: () => void - onReport?: () => void } -export const JobHeader = ({ - job, - isSaved = false, - isApplying = false, - onApply, - onSave, - onReport, -}: JobHeaderProps) => { +export const JobHeader = ({ job }: JobHeaderProps) => { + const [isSaved, setIsSaved] = useState(false) + const [isApplying, setIsApplying] = useState(false) + + const handleSave = async () => { + try { + setIsSaved((prev) => !prev) + await apiFetch(`/api/jobs/${job.id}/save`, { method: 'POST' }) + } catch (err) { + // Gracefully log error if auth session is offline/expired locally + console.warn('Could not persist save action to backend:', err) + } + } + + const handleApply = async () => { + try { + setIsApplying(true) + await apiFetch(`/api/jobs/${job.id}/apply`, { method: 'POST' }) + alert('Application submitted successfully!') + } catch (err) { + console.warn('Could not submit application to backend:', err) + alert('Action logged (Auth session expired or backend offline).') + } finally { + setIsApplying(false) + } + } + + const handleShare = () => { + if (navigator.share) { + navigator.share({ title: job.title, url: window.location.href }) + } else { + navigator.clipboard.writeText(window.location.href) + alert('Link copied to clipboard!') + } + } + return (
- {/* Breadcrumb Navigation & Action Controls */}
Back to Feed
- - + - + +
- {/* Main Role Header */} -
+
- + {job.category} - -

{job.title}

+ +

{job.title}

-
+
- + {job.location} - + {job.salaryRange} - + {job.type}
- {/* Sticky Apply Button wrapper */} -
- +
diff --git a/apps/web/app/components/job/JobSidebar.tsx b/apps/web/app/components/job/JobSidebar.tsx index 80ff08f..e03e1ce 100644 --- a/apps/web/app/components/job/JobSidebar.tsx +++ b/apps/web/app/components/job/JobSidebar.tsx @@ -1,62 +1,57 @@ import React from 'react' import { Building2, Users, Calendar, Code2 } from 'lucide-react' import { Job } from '../../types/job' +import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card' +import { Badge } from '@/components/ui/badge' export const JobSidebar = ({ job }: { job: Job }) => { return ( -
-

- Company Overview -

+ + + Company Overview + -
+
- +
-

Industry

-

- {job.companyOverview?.industry ?? 'Technology'} -

+

Industry

+

{job.companyOverview?.industry ?? 'Technology'}

- +
-

Company Size

-

- {job.companyOverview?.size ?? '50-200 employees'} -

+

Company Size

+

{job.companyOverview?.size ?? '50-200 employees'}

- +
-

Founded

-

{job.companyOverview?.founded ?? '2020'}

+

Founded

+

{job.companyOverview?.founded ?? '2020'}

- +
-

Tech Stack

-
+

Tech Stack

+
{(job.companyOverview?.techStack ?? ['TypeScript', 'Next.js', 'Hono']).map( (tech: string) => ( - + {tech} - + ), )}
-
-
+ +
) } diff --git a/apps/web/app/jobs/[id]/page.tsx b/apps/web/app/jobs/[id]/page.tsx index 6dfbd99..3eed4b4 100644 --- a/apps/web/app/jobs/[id]/page.tsx +++ b/apps/web/app/jobs/[id]/page.tsx @@ -1,106 +1,62 @@ -'use client' - -import React, { useEffect, useState } from 'react' -import { notFound, useParams } from 'next/navigation' +import { notFound } from 'next/navigation' import { AppLayout } from '../../components/layout/AppLayout' import { JobHeader } from '../../components/job/JobHeader' import { JobDetails } from '../../components/job/JobDetails' import { JobSidebar } from '../../components/job/JobSidebar' import { Job } from '../../types/job' -import { apiFetch, ApiError } from '../../../lib/api-client' -export default function JobPostingPage() { - const params = useParams() - const id = params.id as string +interface PageProps { + params: Promise<{ id: string }> +} - const [job, setJob] = useState(null) - const [loading, setLoading] = useState(true) - const [error, setError] = useState(null) +const BASE_URL = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000' - // 1. STATE VARIABLES GO HERE: - const [isSaved, setIsSaved] = useState(false) - const [isApplying, setIsApplying] = useState(false) +async function getJob(id: string): Promise { + try { + const res = await fetch(`${BASE_URL}/api/jobs/${id}`, { + cache: 'no-store', + }) - useEffect(() => { - async function fetchJob() { - try { - setLoading(true) - const data = await apiFetch(`/api/jobs/${id}`) - setJob(data) - } catch (err) { - if (err instanceof ApiError && err.status === 404) { - setJob(null) - } else { - setError(err instanceof Error ? err.message : 'An unexpected error occurred') - } - } finally { - setLoading(false) - } + if (res.status === 404) { + return null } - if (id) { - fetchJob() + if (!res.ok) { + return 'ERROR' } - }, [id]) - // 2. HANDLER FUNCTIONS GO HERE: - const handleSave = async () => { - try { - setIsSaved((prev) => !prev) - await apiFetch(`/api/jobs/${id}/save`, { method: 'POST' }) - } catch (err) { - setIsSaved((prev) => !prev) - console.error('Failed to save job:', err) - } + return await res.json() + } catch (err) { + console.error(`[Jobs API Error]: Fetch failed for job ID ${id}`, err) + return 'ERROR' } +} - const handleApply = async () => { - try { - setIsApplying(true) - await apiFetch(`/api/jobs/${id}/apply`, { method: 'POST' }) - alert('Application submitted successfully!') - } catch (err) { - console.error('Failed to submit application:', err) - alert('Could not submit application. Please try again.') - } finally { - setIsApplying(false) - } - } +export default async function JobPostingPage({ params }: PageProps) { + const { id } = await params + const jobResult = await getJob(id) - if (loading) { - return ( - -
-
-
- - ) + // 1. Trigger Next.js 404 page outside try/catch + if (jobResult === null) { + notFound() } - if (error) { + // 2. Handle 500 or Network connection failures gracefully + if (jobResult === 'ERROR') { return ( -
- Failed to load job posting: {error} +
+ Failed to load job details. Please try again later.
) } - if (!job) { - notFound() - } + const job = jobResult return ( - {/* 3. PROPS ARE PASSED TO JOBHEADER HERE IN THE JSX RETURN: */} - +