Skip to content

Commit 62856c9

Browse files
committed
Implemented jobs page (locally fetching due to JSearch API limitations). Installed Switch shadcn/ui component
1 parent c731eac commit 62856c9

18 files changed

+11219
-15
lines changed

app/(root)/(home)/page.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import LocalSearchbar from "@/components/shared/search/LocalSearchbar";
77
import Filter from "@/components/shared/Filter";
88
import NoResult from "@/components/shared/NoResult";
99
import Pagination from "@/components/shared/Pagination";
10-
import HomeFilters from "@/components/home/HomeFilters";
10+
import HomeFilters from "@/components/shared/Filters";
1111
import QuestionCard from "@/components/cards/QuestionCard";
1212

1313
import {
@@ -78,7 +78,7 @@ export default async function Home({ searchParams }: SearchParamsProps) {
7878
/>
7979
</div>
8080

81-
<HomeFilters />
81+
<HomeFilters filters={HomePageFilters} />
8282

8383
<div className="mt-10 flex w-full flex-col gap-6">
8484
{result.questions.length > 0 ? (

app/(root)/jobs/page.tsx

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
import Filter from "@/components/shared/Filter";
2+
import LocalSearchbar from "@/components/shared/search/LocalSearchbar";
3+
4+
import JobFilters from "@/components/shared/Filters";
5+
import NoResult from "@/components/shared/NoResult";
6+
import Pagination from "@/components/shared/Pagination";
7+
import Switcher from "@/components/shared/Switcher";
8+
import JobCard from "@/components/cards/JobCard";
9+
10+
import { getCountryFilters, getJobs } from "@/lib/actions/job.action";
11+
12+
import { JobPageFilters } from "@/constants/filters";
13+
14+
import type { SearchParamsProps } from "@/types";
15+
16+
const Page = async ({ searchParams }: SearchParamsProps) => {
17+
const CountryFilters = await getCountryFilters();
18+
19+
const result = await getJobs({
20+
searchQuery: searchParams.q,
21+
filter: searchParams.filter,
22+
location: searchParams.location,
23+
remote: searchParams.remote,
24+
page: searchParams.page ? +searchParams.page : 1,
25+
});
26+
27+
return (
28+
<>
29+
<div className="flex flex-row items-center">
30+
<h1 className="h1-bold text-dark100_light900">Jobs</h1>
31+
<Switcher />
32+
</div>
33+
34+
<div className="mt-11 flex justify-between gap-5 max-sm:flex-col sm:items-center">
35+
<LocalSearchbar
36+
route="/jobs"
37+
iconPosition="left"
38+
imgSrc="/assets/icons/search.svg"
39+
placeholder="Job Title, Company, or Keywords"
40+
otherClasses="flex-1"
41+
/>
42+
{CountryFilters && (
43+
<Filter
44+
filters={CountryFilters}
45+
otherClasses="min-h-[56px] sm:min-w-[170px]"
46+
jobFilter
47+
/>
48+
)}
49+
</div>
50+
51+
<JobFilters filters={JobPageFilters} />
52+
53+
<div className="mt-10 flex w-full flex-col gap-6">
54+
{result.data.length > 0 ? (
55+
result.data.map((jobItem: any) => (
56+
<JobCard
57+
key={jobItem.job_id}
58+
title={jobItem.job_title}
59+
description={jobItem.job_description}
60+
city={jobItem.job_city}
61+
state={jobItem.job_state}
62+
country={jobItem.job_country}
63+
requiredSkills={jobItem.job_required_skills?.slice(0, 5) || []}
64+
applyLink={jobItem.job_apply_link}
65+
employerLogo={jobItem.employer_logo}
66+
employerName={jobItem.employer_name}
67+
employerWebsite={jobItem.employer_website}
68+
employmentType={jobItem.job_employment_type?.toLowerCase()}
69+
isRemote={jobItem.job_is_remote}
70+
salary={{
71+
min: jobItem.job_min_salary,
72+
max: jobItem.job_max_salary,
73+
currency: jobItem.job_salary_currency,
74+
period: jobItem.job_salary_period,
75+
}}
76+
postedAt={jobItem.job_posted_at_datetime_utc}
77+
/>
78+
))
79+
) : (
80+
<NoResult
81+
title="No Questions Found"
82+
description="Be the first to break the silence! 🚀 Ask a Question and kickstart the
83+
discussion. our query could be the next big thing others learn from. Get
84+
involved! 💡"
85+
link="/ask-question"
86+
linkTitle="Ask a Question"
87+
/>
88+
)}
89+
</div>
90+
91+
<div className="mt-10">
92+
<Pagination
93+
pageNumber={searchParams?.page ? +searchParams.page : 1}
94+
isNext={result.isNext}
95+
/>
96+
</div>
97+
</>
98+
);
99+
};
100+
101+
export default Page;

components/cards/JobCard.tsx

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
import Link from "next/link";
2+
import Image from "next/image";
3+
4+
import { Badge } from "@/components/ui/badge";
5+
import Metric from "@/components/shared/Metric";
6+
import JobBadge from "@/components/jobs/JobBadge";
7+
8+
import {
9+
employmentTypeConverter,
10+
getFormattedSalary,
11+
getTimestamp,
12+
isValidImage,
13+
} from "@/lib/utils";
14+
15+
interface JobProps {
16+
title: string;
17+
description: string;
18+
city: string;
19+
state: string;
20+
country: string;
21+
requiredSkills: string[];
22+
applyLink: string;
23+
employerLogo: string;
24+
employerWebsite: string;
25+
employerName: string;
26+
employmentType: string;
27+
isRemote: boolean;
28+
salary: {
29+
min: number;
30+
max: number;
31+
currency: string;
32+
period: string;
33+
};
34+
postedAt: string;
35+
}
36+
37+
const JobCard = ({
38+
title,
39+
description,
40+
city,
41+
state,
42+
country,
43+
requiredSkills,
44+
applyLink,
45+
employerLogo,
46+
employerWebsite,
47+
employerName,
48+
employmentType,
49+
isRemote,
50+
salary,
51+
postedAt,
52+
}: JobProps) => {
53+
const imageUrl = isValidImage(employerLogo)
54+
? employerLogo
55+
: "/assets/images/site-logo.svg";
56+
57+
const location = `${city ? `${city}${state ? ", " : ""}` : ""}${state || ""}${
58+
city && state && country ? ", " : ""
59+
}${country || ""}`;
60+
61+
return (
62+
<div className="card-wrapper rounded-[10px]">
63+
<div className="flex flex-row gap-4 p-6">
64+
<div className="hidden sm:block">
65+
<JobBadge data={{ website: employerWebsite, logo: imageUrl }} />
66+
</div>
67+
68+
<div>
69+
<div className="block sm:hidden">
70+
<div className="flex flex-col-reverse items-end">
71+
<JobBadge data={{ location, country }} isLocation />
72+
</div>
73+
</div>
74+
<div className="flex flex-col-reverse items-start justify-between gap-5 sm:flex-row">
75+
<div className="flex-1">
76+
<JobBadge
77+
data={{ website: employerWebsite, logo: imageUrl }}
78+
badgeStyles="mb-6 sm:hidden"
79+
/>
80+
<div className="flex flex-col">
81+
<h3 className="sm:h3-semibold base-semibold text-dark200_light900 line-clamp-2">
82+
{title}
83+
</h3>
84+
<h4 className="paragraph-medium text-dark400_light700">
85+
{employerName}
86+
</h4>
87+
<p className="body-regular text-light-500">
88+
posted {getTimestamp(new Date(postedAt))}
89+
</p>
90+
</div>
91+
</div>
92+
<JobBadge
93+
data={{ location, country }}
94+
badgeStyles="hidden sm:flex"
95+
isLocation
96+
/>
97+
</div>
98+
99+
<p className="body-regular text-dark200_light900 mt-3.5 line-clamp-3">
100+
{description.slice(0, 2000)}
101+
</p>
102+
103+
{requiredSkills && requiredSkills.length > 0 && (
104+
<div className="mt-3.5 flex flex-wrap gap-2">
105+
{requiredSkills.map((tag) => (
106+
<Badge
107+
key={tag}
108+
className="subtle-medium background-light800_dark300 text-light400_light500 rounded-md border-none px-4 py-2 uppercase"
109+
>
110+
{tag}
111+
</Badge>
112+
))}
113+
</div>
114+
)}
115+
116+
<div className="flex-between mt-6 w-full flex-wrap gap-3">
117+
<div className="flex items-center gap-3 max-sm:flex-wrap max-sm:justify-start">
118+
<Metric
119+
imgUrl="/assets/icons/briefcase.svg"
120+
alt="briefcase"
121+
value={employmentTypeConverter(employmentType)}
122+
textStyles="small-medium text-light-500"
123+
/>
124+
<Metric
125+
imgUrl="/assets/icons/people.svg"
126+
alt="people"
127+
value={isRemote ? "Remote" : "On-Site"}
128+
textStyles="small-medium text-light-500"
129+
/>
130+
<Metric
131+
imgUrl="/assets/icons/currency-dollar-circle.svg"
132+
alt="dollar circle"
133+
value={getFormattedSalary(salary) || "TBD"}
134+
textStyles="small-medium text-light-500"
135+
/>
136+
</div>
137+
<Link href={applyLink || "/"} className="flex items-center gap-2">
138+
<p className="body-semibold primary-text-gradient">View job</p>
139+
<Image
140+
alt="arrow up right"
141+
width={20}
142+
height={20}
143+
src="/assets/icons/arrow-up-right.svg"
144+
/>
145+
</Link>
146+
</div>
147+
</div>
148+
</div>
149+
</div>
150+
);
151+
};
152+
153+
export default JobCard;

components/jobs/JobBadge.tsx

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import Image from "next/image";
2+
import Link from "next/link";
3+
4+
import { Badge } from "@/components/ui/badge";
5+
6+
const JobBadge = ({
7+
data,
8+
badgeStyles,
9+
isLocation,
10+
}: {
11+
data: any;
12+
badgeStyles?: string;
13+
isLocation?: boolean;
14+
}) => {
15+
if (isLocation && !data.location) return null;
16+
17+
const classNames = isLocation
18+
? "`subtle-regular background-light800_dark300 text-light400_light500 gap-2 rounded-full border-none px-4 py-2"
19+
: "background-light800_dark400 relative h-16 w-16 rounded-lg";
20+
return (
21+
<Badge className={`${classNames} ${badgeStyles}`}>
22+
{isLocation ? (
23+
<>
24+
{data.location}
25+
{data.country && (
26+
<Image
27+
src={`https://flagsapi.com/${data.country}/flat/64.png`}
28+
width={16}
29+
height={16}
30+
alt="flag"
31+
className="rounded-full"
32+
/>
33+
)}
34+
</>
35+
) : (
36+
<Link href={data.website || "/"}>
37+
<Image
38+
src={data.logo}
39+
fill
40+
alt="logo"
41+
className="object-contain p-2"
42+
/>
43+
</Link>
44+
)}
45+
</Badge>
46+
);
47+
};
48+
49+
export default JobBadge;

components/shared/Filter.tsx

Lines changed: 29 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
"use client";
22

3+
import Image from "next/image";
34
import { useRouter, useSearchParams } from "next/navigation";
45

56
import {
@@ -19,18 +20,25 @@ interface Props {
1920
filters: FilterProps[];
2021
otherClasses?: string;
2122
containerClasses?: string;
23+
jobFilter?: boolean;
2224
}
23-
const Filter = ({ filters, otherClasses, containerClasses }: Props) => {
25+
const Filter = ({
26+
filters,
27+
otherClasses,
28+
containerClasses,
29+
jobFilter = false,
30+
}: Props) => {
2431
const searchParams = useSearchParams();
2532
const router = useRouter();
2633

27-
const paramFilter = searchParams.get("filter");
34+
const searchParamKey = jobFilter ? "location" : "filter";
35+
const paramFilter = searchParams.get(searchParamKey);
2836

2937
const handleUpdateParams = (value: string) => {
3038
const newUrl = formUrlQuery({
3139
params: searchParams.toString(),
32-
key: "filter",
33-
value,
40+
key: searchParamKey,
41+
value: jobFilter ? value.toLowerCase() : value,
3442
});
3543

3644
router.push(newUrl, { scroll: false });
@@ -46,17 +54,32 @@ const Filter = ({ filters, otherClasses, containerClasses }: Props) => {
4654
className={`${otherClasses} body-regular light-border background-light800_dark300 text-dark500_light700 border px-5 py-2.5`}
4755
>
4856
<div className="line-clamp-1 flex-1 text-left">
49-
<SelectValue placeholder="Select a Filter" />
57+
<SelectValue
58+
placeholder={jobFilter ? "Select Location" : "Select a Filter"}
59+
/>
5060
</div>
5161
</SelectTrigger>
52-
<SelectContent className="text-dark500_light700 small-regular border-none bg-light-900 dark:bg-dark-300">
62+
<SelectContent
63+
className={`text-dark500_light700 small-regular border-none bg-light-900 dark:bg-dark-300 ${
64+
jobFilter && "max-h-[12rem] overflow-y-auto"
65+
}`}
66+
>
5367
<SelectGroup>
5468
{filters.map((filter) => (
5569
<SelectItem
5670
key={filter.value}
5771
value={filter.value}
5872
className="cursor-pointer focus:bg-light-800 dark:focus:bg-dark-400"
5973
>
74+
{jobFilter && (
75+
<Image
76+
src={`https://flagsapi.com/${filter.value}/flat/64.png`}
77+
width={16}
78+
height={16}
79+
alt="flag"
80+
className="mr-2 inline-flex rounded-lg"
81+
/>
82+
)}
6083
{filter.name}
6184
</SelectItem>
6285
))}

0 commit comments

Comments
 (0)