diff --git a/app/(root)/jobs/loading.tsx b/app/(root)/jobs/loading.tsx new file mode 100644 index 0000000..e9377db --- /dev/null +++ b/app/(root)/jobs/loading.tsx @@ -0,0 +1,29 @@ +import LocalSearchbar from "@/components/shared/search/LocalSearchbar"; +import { Skeleton } from "@/components/ui/skeleton"; + +const Loading = () => { + return ( +
+

Jobs

+ +
+ + +
+ +
+ {[1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map((item) => ( + + ))} +
+
+ ); +}; + +export default Loading; diff --git a/app/(root)/jobs/page.tsx b/app/(root)/jobs/page.tsx new file mode 100644 index 0000000..5c4ef4b --- /dev/null +++ b/app/(root)/jobs/page.tsx @@ -0,0 +1,73 @@ +import UserCard from "@/components/cards/UserCard"; +import Filter from "@/components/shared/Filter"; +import NoResults from "@/components/shared/NoResults"; +import Pagination from "@/components/shared/Pagination"; +import LocalSearchbar from "@/components/shared/search/LocalSearchbar"; +import { UserFilters } from "@/constants/filters"; +import { getAllUsers } from "@/lib/actions/user.action"; +import { SearchParamsProps } from "@/types"; +import { Metadata } from "next"; + +import { + fetchCountries, + fetchJobs, + fetchLocation, +} from "@/lib/actions/job.action"; + +import { Job } from "@/types"; +import JobsFilter from "@/components/jobs/JobsFilter"; +import JobCard from "@/components/cards/JobCard"; + +interface JobsPageProps { + searchParams: { + q: string; + location: string; + page: string; + }; +} + +export const metadata: Metadata = { + title: "Jobs | Dev Overflow", +}; + +export default async function JobsPage({ searchParams }: JobsPageProps) { + const userLocation = await fetchLocation(); + + const jobs = await fetchJobs({ + query: + `${searchParams.q},${searchParams.location}` ?? + `Software Engineer in ${userLocation}`, + page: searchParams.page ?? 1, + }); + + const countries = await fetchCountries(); + const page = parseInt(searchParams.page ?? 1); + + return ( + <> +

Jobs

+ +
+ +
+ +
+ {jobs && jobs.length > 0 ? ( + jobs.map((job: Job, index: number) => ( + + )) + ) : ( + + )} +
+
+ +
+ + ); +} diff --git a/app/layout.tsx b/app/layout.tsx index e3ffc05..c4df074 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -4,6 +4,8 @@ import type { Metadata } from "next"; import "./globals.css"; import "../styles/prism.css"; import { ThemeProvider } from "@/context/ThemeProvider"; +import { SpeedInsights } from "@vercel/speed-insights/next"; +import { Analytics } from "@vercel/analytics/react"; const inter = Inter({ subsets: ["latin"], @@ -42,7 +44,11 @@ export default function RootLayout({ }, }} > - {children} + + {children} + + + diff --git a/components/cards/JobCard.tsx b/components/cards/JobCard.tsx new file mode 100644 index 0000000..aef31a1 --- /dev/null +++ b/components/cards/JobCard.tsx @@ -0,0 +1,136 @@ +import RenderTag from "../shared/RenderTag"; +import { + formatAndDivideNumber, + getTimestamp, + processJobTitle, +} from "@/lib/utils"; +import Link from "next/link"; +import Metric from "../shared/Metric"; +import { toast } from "../ui/use-toast"; +import { title } from "process"; +import QuestionCard from "./QuestionCard"; +import { Job } from "@/types"; +import Image from "next/image"; +import JobLocation from "../jobs/JobLocation"; + +interface JobCardProps { + job: Job; +} + +const JobCard = ({ job }: JobCardProps) => { + const { + employer_logo, + employer_website, + job_employment_type, + job_title, + job_description, + job_apply_link, + job_city, + job_state, + job_country, + } = job; + + return ( +
+
+ +
+ +
+ {employer_logo ? ( + + Company logo + + ) : ( + Default site logo + )} +
+ +
+
+

+ {processJobTitle(job_title)} +

+ +
+ +
+
+ +

+ {job_description?.slice(0, 200)} +

+ +
+
+
+ clock +

+ {job_employment_type} +

+
+ +
+ dollar symbol +

Not disclosed

+
+
+ + +

View job

+ + arrow up right + +
+
+
+ ); +}; + +export default JobCard; diff --git a/components/jobs/JobLocation.tsx b/components/jobs/JobLocation.tsx new file mode 100644 index 0000000..16f9e59 --- /dev/null +++ b/components/jobs/JobLocation.tsx @@ -0,0 +1,35 @@ +import Image from "next/image"; +import React from "react"; + +interface JobLocationProps { + job_country?: string; + job_city?: string; + job_state?: string; +} + +const JobLocation = ({ + job_country, + job_city, + job_state, +}: JobLocationProps) => { + return ( +
+ Country flag + +

+ {job_city && `${job_city}, `} + {job_state && `${job_state}, `} + {job_country && `${job_country}`} +

+
+ ); +}; + +export default JobLocation; diff --git a/components/jobs/JobsFilter.tsx b/components/jobs/JobsFilter.tsx new file mode 100644 index 0000000..ddff24f --- /dev/null +++ b/components/jobs/JobsFilter.tsx @@ -0,0 +1,83 @@ +"use client"; + +import { formUrlQuery } from "@/lib/utils"; +import { Country } from "@/types"; +import { usePathname, useRouter, useSearchParams } from "next/navigation"; +import React from "react"; +import LocalSearchbar from "../shared/search/LocalSearchbar"; +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectTrigger, + SelectValue, +} from "../ui/select"; +import Image from "next/image"; + +interface JobsFilterProps { + countriesList: Country[]; +} + +const JobsFilter = ({ countriesList }: JobsFilterProps) => { + const router = useRouter(); + const path = usePathname(); + const searchParams = useSearchParams(); + + const handleUpdateParams = (value: string) => { + const newUrl = formUrlQuery({ + params: searchParams.toString(), + key: "location", + value, + }); + + router.push(newUrl, { scroll: false }); + }; + + return ( +
+ + + +
+ ); +}; + +export default JobsFilter; diff --git a/components/shared/LeftSidebar.tsx b/components/shared/LeftSidebar.tsx index de21b3f..fb4bd68 100644 --- a/components/shared/LeftSidebar.tsx +++ b/components/shared/LeftSidebar.tsx @@ -7,12 +7,15 @@ import Image from "next/image"; import { SignOutButton, SignedIn, SignedOut, useAuth } from "@clerk/nextjs"; import { Button } from "../ui/button"; import Logo from "./navbar/Logo"; +import { motion } from "framer-motion"; const LeftSidebar = () => { const { userId } = useAuth(); + const pathname = usePathname(); return ( + // eslint-disable-next-line tailwindcss/migration-from-tailwind-2
@@ -36,26 +39,32 @@ const LeftSidebar = () => { href={item.route} key={item.route} className={`${ - isActive - ? "primary-gradient text-light-900" - : "text-dark300_light900" - } hover:background-hover flex items-center justify-start gap-4 rounded-lg bg-transparent p-4`} + isActive ? "text-light-900" : "text-dark300_light900" + } hover:background-hover relative rounded-lg bg-transparent`} > - {item.label} -

- {item.label} -

+ {isActive && ( + + )} + + {item.label} +

+ {item.label} +

+
); })} @@ -64,7 +73,7 @@ const LeftSidebar = () => {
- @@ -88,7 +95,7 @@ const MobileNav = () => { - @@ -98,7 +105,7 @@ const MobileNav = () => { -
+
{ + try { + const response = await fetch("http://ip-api.com/json/?fields=country"); + const location = await response.json(); + + return location.country; + } catch (error) { + return { + message: getErrorMessage(error), + }; + } +}; + +export const fetchCountries = async () => { + try { + const response = await fetch("https://restcountries.com/v3.1/all"); + const result = await response.json(); + return result; + } catch (error) { + return { + message: getErrorMessage(error), + }; + } +}; + +export const fetchJobs = async (filters: JobFilterParams) => { + const { query, page } = filters; + + try { + const headers = { + "X-RapidAPI-Key": process.env.NEXT_PUBLIC_RAPID_API_KEY ?? "", + "X-RapidAPI-Host": "jsearch.p.rapidapi.com", + }; + + const response = await fetch( + `https://jsearch.p.rapidapi.com/search?query=${query}&page=${page}`, + { + headers, + } + ); + + const result = await response.json(); + + return result.data; + } catch (error) { + return { + message: getErrorMessage(error), + }; + } +}; diff --git a/lib/utils.ts b/lib/utils.ts index 8072ed1..8a76c2c 100644 --- a/lib/utils.ts +++ b/lib/utils.ts @@ -164,3 +164,33 @@ export const assignBadges = (params: BadgeParam) => { return badgeCounts; }; + +export function processJobTitle(title: string | undefined | null): string { + // Check if title is undefined or null + if (title === undefined || title === null) { + return "No Job Title"; + } + + // Split the title into words + const words = title.split(" "); + + // Filter out undefined or null and other unwanted words + const validWords = words.filter((word) => { + return ( + word !== undefined && + word !== null && + word.toLowerCase() !== "undefined" && + word.toLowerCase() !== "null" + ); + }); + + // If no valid words are left, return the general title + if (validWords.length === 0) { + return "No Job Title"; + } + + // Join the valid words to create the processed title + const processedTitle = validWords.join(" "); + + return processedTitle; +} diff --git a/package-lock.json b/package-lock.json index 4b5af9c..29869de 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,11 +21,14 @@ "@radix-ui/react-toast": "^1.1.5", "@tailwindcss/typography": "^0.5.10", "@tinymce/tinymce-react": "^4.3.2", + "@vercel/analytics": "^1.1.1", + "@vercel/speed-insights": "^1.0.1", "class-variance-authority": "^0.7.0", "clsx": "^2.0.0", "eslint-config-prettier": "^9.1.0", "eslint-config-standard": "^17.1.0", "eslint-plugin-tailwindcss": "^3.13.0", + "framer-motion": "^10.16.16", "html-react-parser": "^5.0.6", "lucide-react": "^0.294.0", "mongodb": "^6.3.0", @@ -246,6 +249,21 @@ "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.1.tgz", "integrity": "sha512-DJR/VvkAvSZW9bTouZue2sSxDwdTN92uHjqeKVm+0dAqdfNykRzQ95tay8aXMBAAPpUiq4Qcug2L7neoRh2Egw==" }, + "node_modules/@emotion/is-prop-valid": { + "version": "0.8.8", + "resolved": "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-0.8.8.tgz", + "integrity": "sha512-u5WtneEAr5IDG2Wv65yhunPSMLIpuKsbuOktRojfrEiEvRyC85LgPMZI63cr7NUqT8ZIGdSVg8ZKGxIug4lXcA==", + "optional": true, + "dependencies": { + "@emotion/memoize": "0.7.4" + } + }, + "node_modules/@emotion/memoize": { + "version": "0.7.4", + "resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.7.4.tgz", + "integrity": "sha512-Ja/Vfqe3HpuzRsG1oBtWTHk2PGZ7GR+2Vz5iYGelAw8dx32K0y7PjVuxK6z1nMpZOqAFsRUPCkK1YjJ56qJlgw==", + "optional": true + }, "node_modules/@eslint-community/eslint-utils": { "version": "4.4.0", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.4.0.tgz", @@ -1738,6 +1756,20 @@ "integrity": "sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==", "dev": true }, + "node_modules/@vercel/analytics": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@vercel/analytics/-/analytics-1.1.1.tgz", + "integrity": "sha512-+NqgNmSabg3IFfxYhrWCfB/H+RCUOCR5ExRudNG2+pcRehq628DJB5e1u1xqwpLtn4pAYii4D98w7kofORAGQA==", + "dependencies": { + "server-only": "^0.0.1" + } + }, + "node_modules/@vercel/speed-insights": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@vercel/speed-insights/-/speed-insights-1.0.1.tgz", + "integrity": "sha512-cm8KTTsDgS1AbWsgIEZuMoyPUjclzeqJihyLp0tnA21B/x9iTE8hu2S5zM+/DBzihuHxWL1dx9pCWk22ctMFWQ==", + "hasInstallScript": true + }, "node_modules/acorn": { "version": "8.11.2", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.11.2.tgz", @@ -3409,6 +3441,29 @@ "url": "https://github.com/sponsors/rawify" } }, + "node_modules/framer-motion": { + "version": "10.16.16", + "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-10.16.16.tgz", + "integrity": "sha512-je6j91rd7NmUX7L1XHouwJ4v3R+SO4umso2LUcgOct3rHZ0PajZ80ETYZTajzEXEl9DlKyzjyt4AvGQ+lrebOw==", + "dependencies": { + "tslib": "^2.4.0" + }, + "optionalDependencies": { + "@emotion/is-prop-valid": "^0.8.2" + }, + "peerDependencies": { + "react": "^18.0.0", + "react-dom": "^18.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, "node_modules/fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", @@ -5648,6 +5703,11 @@ "node": ">=10" } }, + "node_modules/server-only": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/server-only/-/server-only-0.0.1.tgz", + "integrity": "sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA==" + }, "node_modules/set-function-length": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.1.1.tgz", diff --git a/package.json b/package.json index a71779d..a29545d 100644 --- a/package.json +++ b/package.json @@ -22,11 +22,14 @@ "@radix-ui/react-toast": "^1.1.5", "@tailwindcss/typography": "^0.5.10", "@tinymce/tinymce-react": "^4.3.2", + "@vercel/analytics": "^1.1.1", + "@vercel/speed-insights": "^1.0.1", "class-variance-authority": "^0.7.0", "clsx": "^2.0.0", "eslint-config-prettier": "^9.1.0", "eslint-config-standard": "^17.1.0", "eslint-plugin-tailwindcss": "^3.13.0", + "framer-motion": "^10.16.16", "html-react-parser": "^5.0.6", "lucide-react": "^0.294.0", "mongodb": "^6.3.0",