From be5fe226b689a1a16e7fd97568fea755b46ef6b4 Mon Sep 17 00:00:00 2001 From: ladunjexa <110838700+ladunjexa@users.noreply.github.com> Date: 2023年10月31日 06:32:02 +0200 Subject: [PATCH 01/45] Refactored username assignment to use fallback based on user email address --- app/api/webhook/clerk/route.ts | 6 +++++- components/forms/Profile.tsx | 2 ++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/app/api/webhook/clerk/route.ts b/app/api/webhook/clerk/route.ts index 8217765..bd4f418 100644 --- a/app/api/webhook/clerk/route.ts +++ b/app/api/webhook/clerk/route.ts @@ -60,15 +60,19 @@ export async function POST(req: Request) { const { id, email_addresses, image_url, username, first_name, last_name } = evt.data; + const parts = email_addresses[0].email_address.split("@"); + // create a new user in database const mongoUser = await createUser({ clerkId: id, name: `${first_name}${last_name ? ` ${last_name}` : ""}`, - username: username || "", + username: username || `${parts[0]}-${parts[1].split(".")[0]}`, email: email_addresses[0].email_address, picture: image_url, }); + console.log(mongoUser); + return NextResponse.json({ message: "User created", user: mongoUser }); } diff --git a/components/forms/Profile.tsx b/components/forms/Profile.tsx index 995b670..bb42e16 100644 --- a/components/forms/Profile.tsx +++ b/components/forms/Profile.tsx @@ -32,6 +32,7 @@ const Profile = ({ clerkId, user }: Props) => { const router = useRouter(); const pathname = usePathname(); const parsedUser = JSON.parse(user); + const [isSubmitting, setIsSubmitting] = useState(false); const form = useForm>({ @@ -61,6 +62,7 @@ const Profile = ({ clerkId, user }: Props) => { }, path: pathname, }); + router.push("/"); } catch (error) { toast({ From 68721fbc8f0379c5fe2521698bebfa15d606d38b Mon Sep 17 00:00:00 2001 From: ladunjexa <110838700+ladunjexa@users.noreply.github.com> Date: 2023年10月31日 06:41:45 +0200 Subject: [PATCH 02/45] Added `type` prop with the key `Create` for specific behavior --- app/(root)/question/[id]/page.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/app/(root)/question/[id]/page.tsx b/app/(root)/question/[id]/page.tsx index 0b19a03..7ddcf79 100644 --- a/app/(root)/question/[id]/page.tsx +++ b/app/(root)/question/[id]/page.tsx @@ -135,6 +135,7 @@ const Page = async ({ params, searchParams }: URLProps) => { /> Date: 2023年10月31日 11:57:56 +0200 Subject: [PATCH 03/45] Implemented JSearch api route for jobs. Refactored OpenAI api route --- app/api/openai/route.ts | 17 ++++++++++++----- app/api/rapidapi/route.ts | 35 +++++++++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+), 5 deletions(-) create mode 100644 app/api/rapidapi/route.ts diff --git a/app/api/openai/route.ts b/app/api/openai/route.ts index 8e0a98f..2e4cae2 100644 --- a/app/api/openai/route.ts +++ b/app/api/openai/route.ts @@ -1,26 +1,33 @@ import { NextResponse } from "next/server"; +const config = { + apiKey: process.env.OPENAI_API_KEY as string, + apiHost: "https://api.openai.com/v1/chat/completions", + systemContent: + "You are a knowlegeable assistant that provides quality information.", + userContent: (question: string) => `Tell me ${question}`, +}; + export const POST = async (request: Request) => { const { question } = await request.json(); try { - const response = await fetch("https://api.openai.com/v1/chat/completions", { + const response = await fetch(config.apiHost, { method: "POST", headers: { "Content-Type": "application/json", - Authorization: `Bearer ${process.env.OPENAI_API_KEY}`, + Authorization: `Bearer ${config.apiKey}}`, }, body: JSON.stringify({ model: "gpt-3.5-turbo", messages: [ { role: "system", - content: - "You are a knowlegeable assistant that provides quality information.", + content: config.systemContent, }, { role: "user", - content: `Tell me ${question}`, + content: config.userContent(question), }, ], }), diff --git a/app/api/rapidapi/route.ts b/app/api/rapidapi/route.ts new file mode 100644 index 0000000..7d7fa77 --- /dev/null +++ b/app/api/rapidapi/route.ts @@ -0,0 +1,35 @@ +import { NextResponse } from "next/server"; + +const config = { + apiKey: process.env.RAPIDAPI_API_KEY as string, + apiHost: "https://jsearch.p.rapidapi.com", +}; + +export const POST = async (request: Request) => { + const { + page = 1, + pageSize = 1, + filter = "us", + searchQuery = "Software Engineer", + } = await request.json(); + + try { + const response = await fetch( + `${config.apiHost}/search?query=${searchQuery}&location=${filter}&page=${page}&num_pages=${pageSize}`, + { + method: "GET", + headers: { + "X-RapidAPI-Key": config.apiKey, + "X-RapidAPI-Host": config.apiHost, + }, + } + ); + + const responseData = await response.json(); + const result = responseData.result; + + return NextResponse.json({ result }); + } catch (error: any) { + return NextResponse.json({ error: error.message }); + } +}; From 62856c9c25958e7e06cce344c90816ee172b2c5a Mon Sep 17 00:00:00 2001 From: ladunjexa <110838700+ladunjexa@users.noreply.github.com> Date: 2023年10月31日 15:35:51 +0200 Subject: [PATCH 04/45] Implemented jobs page (locally fetching due to JSearch API limitations). Installed `Switch` shadcn/ui component --- app/(root)/(home)/page.tsx | 4 +- app/(root)/jobs/page.tsx | 101 + components/cards/JobCard.tsx | 153 + components/jobs/JobBadge.tsx | 49 + components/shared/Filter.tsx | 35 +- .../HomeFilters.tsx => shared/Filters.tsx} | 8 +- components/shared/Switcher.tsx | 59 + components/ui/switch.tsx | 29 + constants/filters.ts | 7 + constants/index.ts | 115 + content/countries.json | 198 + content/jsearch.json | 10260 ++++++++++++++++ lib/actions/job.action.ts | 105 + lib/actions/shared.types.d.ts | 12 +- lib/utils.ts | 65 +- middleware.ts | 3 +- package-lock.json | 30 + package.json | 1 + 18 files changed, 11219 insertions(+), 15 deletions(-) create mode 100644 app/(root)/jobs/page.tsx create mode 100644 components/cards/JobCard.tsx create mode 100644 components/jobs/JobBadge.tsx rename components/{home/HomeFilters.tsx => shared/Filters.tsx} (89%) create mode 100644 components/shared/Switcher.tsx create mode 100644 components/ui/switch.tsx create mode 100644 content/countries.json create mode 100644 content/jsearch.json create mode 100644 lib/actions/job.action.ts diff --git a/app/(root)/(home)/page.tsx b/app/(root)/(home)/page.tsx index a92779b..5f0da62 100644 --- a/app/(root)/(home)/page.tsx +++ b/app/(root)/(home)/page.tsx @@ -7,7 +7,7 @@ import LocalSearchbar from "@/components/shared/search/LocalSearchbar"; import Filter from "@/components/shared/Filter"; import NoResult from "@/components/shared/NoResult"; import Pagination from "@/components/shared/Pagination"; -import HomeFilters from "@/components/home/HomeFilters"; +import HomeFilters from "@/components/shared/Filters"; import QuestionCard from "@/components/cards/QuestionCard"; import { @@ -78,7 +78,7 @@ export default async function Home({ searchParams }: SearchParamsProps) { /> - +
{result.questions.length> 0 ? ( diff --git a/app/(root)/jobs/page.tsx b/app/(root)/jobs/page.tsx new file mode 100644 index 0000000..4b0a526 --- /dev/null +++ b/app/(root)/jobs/page.tsx @@ -0,0 +1,101 @@ +import Filter from "@/components/shared/Filter"; +import LocalSearchbar from "@/components/shared/search/LocalSearchbar"; + +import JobFilters from "@/components/shared/Filters"; +import NoResult from "@/components/shared/NoResult"; +import Pagination from "@/components/shared/Pagination"; +import Switcher from "@/components/shared/Switcher"; +import JobCard from "@/components/cards/JobCard"; + +import { getCountryFilters, getJobs } from "@/lib/actions/job.action"; + +import { JobPageFilters } from "@/constants/filters"; + +import type { SearchParamsProps } from "@/types"; + +const Page = async ({ searchParams }: SearchParamsProps) => { + const CountryFilters = await getCountryFilters(); + + const result = await getJobs({ + searchQuery: searchParams.q, + filter: searchParams.filter, + location: searchParams.location, + remote: searchParams.remote, + page: searchParams.page ? +searchParams.page : 1, + }); + + return ( + +
+

Jobs

+ +
+ +
+ + {CountryFilters && ( + + )} +
+ + + +
+ {result.data.length> 0 ? ( + result.data.map((jobItem: any) => ( + + )) + ) : ( + + )} +
+ +
+ +
+
+ ); +}; + +export default Page; diff --git a/components/cards/JobCard.tsx b/components/cards/JobCard.tsx new file mode 100644 index 0000000..3d1d4f9 --- /dev/null +++ b/components/cards/JobCard.tsx @@ -0,0 +1,153 @@ +import Link from "next/link"; +import Image from "next/image"; + +import { Badge } from "@/components/ui/badge"; +import Metric from "@/components/shared/Metric"; +import JobBadge from "@/components/jobs/JobBadge"; + +import { + employmentTypeConverter, + getFormattedSalary, + getTimestamp, + isValidImage, +} from "@/lib/utils"; + +interface JobProps { + title: string; + description: string; + city: string; + state: string; + country: string; + requiredSkills: string[]; + applyLink: string; + employerLogo: string; + employerWebsite: string; + employerName: string; + employmentType: string; + isRemote: boolean; + salary: { + min: number; + max: number; + currency: string; + period: string; + }; + postedAt: string; +} + +const JobCard = ({ + title, + description, + city, + state, + country, + requiredSkills, + applyLink, + employerLogo, + employerWebsite, + employerName, + employmentType, + isRemote, + salary, + postedAt, +}: JobProps) => { + const imageUrl = isValidImage(employerLogo) + ? employerLogo + : "/assets/images/site-logo.svg"; + + const location = `${city ? `${city}${state ? ", " : ""}` : ""}${state || ""}${ + city && state && country ? ", " : "" + }${country || ""}`; + + return ( +
+
+
+ +
+ +
+
+
+ +
+
+
+
+ +
+

+ {title} +

+

+ {employerName} +

+

+ posted {getTimestamp(new Date(postedAt))} +

+
+
+ +
+ +

+ {description.slice(0, 2000)} +

+ + {requiredSkills && requiredSkills.length> 0 && ( +
+ {requiredSkills.map((tag) => ( + + {tag} + + ))} +
+ )} + +
+
+ + + +
+ +

View job

+ arrow up right + +
+
+
+
+ ); +}; + +export default JobCard; diff --git a/components/jobs/JobBadge.tsx b/components/jobs/JobBadge.tsx new file mode 100644 index 0000000..1657207 --- /dev/null +++ b/components/jobs/JobBadge.tsx @@ -0,0 +1,49 @@ +import Image from "next/image"; +import Link from "next/link"; + +import { Badge } from "@/components/ui/badge"; + +const JobBadge = ({ + data, + badgeStyles, + isLocation, +}: { + data: any; + badgeStyles?: string; + isLocation?: boolean; +}) => { + if (isLocation && !data.location) return null; + + const classNames = isLocation + ? "`subtle-regular background-light800_dark300 text-light400_light500 gap-2 rounded-full border-none px-4 py-2" + : "background-light800_dark400 relative h-16 w-16 rounded-lg"; + return ( + + {isLocation ? ( + + {data.location} + {data.country && ( + flag + )} + + ) : ( + + logo + + )} + + ); +}; + +export default JobBadge; diff --git a/components/shared/Filter.tsx b/components/shared/Filter.tsx index 280b849..1e9a656 100644 --- a/components/shared/Filter.tsx +++ b/components/shared/Filter.tsx @@ -1,5 +1,6 @@ "use client"; +import Image from "next/image"; import { useRouter, useSearchParams } from "next/navigation"; import { @@ -19,18 +20,25 @@ interface Props { filters: FilterProps[]; otherClasses?: string; containerClasses?: string; + jobFilter?: boolean; } -const Filter = ({ filters, otherClasses, containerClasses }: Props) => { +const Filter = ({ + filters, + otherClasses, + containerClasses, + jobFilter = false, +}: Props) => { const searchParams = useSearchParams(); const router = useRouter(); - const paramFilter = searchParams.get("filter"); + const searchParamKey = jobFilter ? "location" : "filter"; + const paramFilter = searchParams.get(searchParamKey); const handleUpdateParams = (value: string) => { const newUrl = formUrlQuery({ params: searchParams.toString(), - key: "filter", - value, + key: searchParamKey, + value: jobFilter ? value.toLowerCase() : value, }); router.push(newUrl, { scroll: false }); @@ -46,10 +54,16 @@ const Filter = ({ filters, otherClasses, containerClasses }: Props) => { className={`${otherClasses} body-regular light-border background-light800_dark300 text-dark500_light700 border px-5 py-2.5`} >
- +
- + {filters.map((filter) => ( { value={filter.value} className="cursor-pointer focus:bg-light-800 dark:focus:bg-dark-400" > + {jobFilter && ( + flag + )} {filter.name} ))} diff --git a/components/home/HomeFilters.tsx b/components/shared/Filters.tsx similarity index 89% rename from components/home/HomeFilters.tsx rename to components/shared/Filters.tsx index 217ef13..c3f7dac 100644 --- a/components/home/HomeFilters.tsx +++ b/components/shared/Filters.tsx @@ -7,9 +7,9 @@ import { Button } from "@/components/ui/button"; import { formUrlQuery } from "@/lib/utils"; -import { HomePageFilters } from "@/constants/filters"; +import type { FilterProps } from "@/types"; -const HomeFilters = () => { +const Filters = ({ filters }: { filters: FilterProps[] }) => { const searchParams = useSearchParams(); const router = useRouter(); @@ -41,7 +41,7 @@ const HomeFilters = () => { return (
- {HomePageFilters.map((filter) => ( + {filters.map((filter) => (
@@ -134,7 +134,12 @@ const JobCard = ({ textStyles="small-medium text-light-500" />
- +

View job

arrow up right { +const Filters = ({ + filters, + jobFilter = false, +}: { + filters: FilterProps[]; + jobFilter: boolean; +}) => { const searchParams = useSearchParams(); const router = useRouter(); @@ -54,6 +61,14 @@ const Filters = ({ filters }: { filters: FilterProps[] }) => { {filter.name} ))} + + {jobFilter && ( +
+ + + +
+ )} ); }; diff --git a/components/shared/Switcher.tsx b/components/shared/Switcher.tsx index d3e7da3..128a26a 100644 --- a/components/shared/Switcher.tsx +++ b/components/shared/Switcher.tsx @@ -8,18 +8,15 @@ import { Switch } from "@/components/ui/switch"; import { formUrlQuery, removeKeysFromQuery } from "@/lib/utils"; interface Props { - searchParamKey?: string; - label?: string; + query: string; + label: string; } -const Switcher = ({ - searchParamKey = "remote", - label = "Remote jobs only", -}: Props) => { +const Switcher = ({ query, label }: Props) => { const searchParams = useSearchParams(); const router = useRouter(); - const paramFilter = searchParams.get(searchParamKey); + const paramFilter = searchParams.get(query); const handleUpdateParams = (value: string) => { let newUrl; @@ -27,12 +24,12 @@ const Switcher = ({ if (!value) { newUrl = removeKeysFromQuery({ params: searchParams.toString(), - keysToRemove: [searchParamKey], + keysToRemove: [query], }); } else { newUrl = formUrlQuery({ params: searchParams.toString(), - key: searchParamKey, + key: query, value, }); } @@ -43,13 +40,13 @@ const Switcher = ({ return ( - diff --git a/components/ui/switch.tsx b/components/ui/switch.tsx index 9459716..36fc85a 100644 --- a/components/ui/switch.tsx +++ b/components/ui/switch.tsx @@ -11,7 +11,7 @@ const Switch = React.forwardRef(({ className, ...props }, ref) => ( Date: Wed, 1 Nov 2023 05:38:49 +0200 Subject: [PATCH 09/45] Implemented Loading State for jobs page --- app/(root)/jobs/loading.tsx | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 app/(root)/jobs/loading.tsx diff --git a/app/(root)/jobs/loading.tsx b/app/(root)/jobs/loading.tsx new file mode 100644 index 0000000..b9822c5 --- /dev/null +++ b/app/(root)/jobs/loading.tsx @@ -0,0 +1,34 @@ +import { Skeleton } from "@/components/ui/skeleton"; + +const Loading = () => { + return ( +
+

Jobs

+ +
+ + +
+ +
+
+ + + + +
+
+ +
+
+ +
+ {[...Array(10)].map((_, i) => ( + + ))} +
+
+ ); +}; + +export default Loading; From 14367d1a1db5918e9038f4b77ac0af501b94fa1d Mon Sep 17 00:00:00 2001 From: ladunjexa <110838700+ladunjexa@users.noreply.github.com> Date: Wed, 1 Nov 2023 05:47:17 +0200 Subject: [PATCH 10/45] Optimized job actions by caching data for improved efficiency --- lib/actions/job.action.ts | 27 +++++++++++++++++++-------- 1 file changed, 19 insertions(+), 8 deletions(-) diff --git a/lib/actions/job.action.ts b/lib/actions/job.action.ts index 50cf73d..7a267bc 100644 --- a/lib/actions/job.action.ts +++ b/lib/actions/job.action.ts @@ -3,6 +3,9 @@ import path from "path"; import type { GetJobsParams } from "./shared.types"; +let _jsearch: any; +let _countries: any; + export async function getJobs(params: GetJobsParams) { try { const { @@ -19,12 +22,16 @@ export async function getJobs(params: GetJobsParams) { // Calculate the number of jobs to skip based on the page number and page size const skipAmount = (page - 1) * pageSize; - const file = path.join(process.cwd(), "content", "jsearch.json"); - const fileSync = readFileSync(file, "utf8"); + if (!_jsearch) { + const file = path.join(process.cwd(), "content", "jsearch.json"); + const fileSync = readFileSync(file, "utf8"); + + const jsonData = JSON.parse(fileSync); - const jsonData = JSON.parse(fileSync); + _jsearch = jsonData; + } - const allJobs = jsonData.data || []; + const allJobs = _jsearch.data || []; const searchQueryRegExp = new RegExp( (searchQuery || "").toLowerCase(), @@ -87,12 +94,16 @@ export async function getJobs(params: GetJobsParams) { export async function getCountryFilters() { try { - const file = path.join(process.cwd(), "content", "countries.json"); - const fileSync = readFileSync(file, "utf8"); + if (!_countries) { + const file = path.join(process.cwd(), "content", "countries.json"); + const fileSync = readFileSync(file, "utf8"); - const jsonData = JSON.parse(fileSync); + const jsonData = JSON.parse(fileSync); + + _countries = jsonData; + } - const result = jsonData.map((country: any) => ({ + const result = _countries.map((country: any) => ({ name: country.name, value: country.cca2, })); From addf9b214d291946fdeb24aa2ecc7fa50dbad77a Mon Sep 17 00:00:00 2001 From: ladunjexa <110838700+ladunjexa@users.noreply.github.com> Date: Wed, 1 Nov 2023 05:48:10 +0200 Subject: [PATCH 11/45] Implemented static metadata for jobs page --- app/(root)/jobs/page.tsx | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/app/(root)/jobs/page.tsx b/app/(root)/jobs/page.tsx index ff89a02..18d1a82 100644 --- a/app/(root)/jobs/page.tsx +++ b/app/(root)/jobs/page.tsx @@ -11,6 +11,11 @@ import { getCountryFilters, getJobs } from "@/lib/actions/job.action"; import { JobPageFilters } from "@/constants/filters"; import type { SearchParamsProps } from "@/types"; +import type { Metadata } from "next"; + +export const metadata: Metadata = { + title: "Jobs — DevOverflow", +}; const Page = async ({ searchParams }: SearchParamsProps) => { const CountryFilters = await getCountryFilters(); From 3dfa80ce16f29a31e6e4e1a5ece2729d9b1e0f50 Mon Sep 17 00:00:00 2001 From: ladunjexa <110838700+ladunjexa@users.noreply.github.com> Date: Wed, 1 Nov 2023 05:51:23 +0200 Subject: [PATCH 12/45] Refactored `Filters` component by making `jobFilter` an optional prop to resolve Type Error. --- components/shared/Filters.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/components/shared/Filters.tsx b/components/shared/Filters.tsx index 3a95285..4193039 100644 --- a/components/shared/Filters.tsx +++ b/components/shared/Filters.tsx @@ -15,7 +15,7 @@ const Filters = ({ jobFilter = false, }: { filters: FilterProps[]; - jobFilter: boolean; + jobFilter?: boolean; }) => { const searchParams = useSearchParams(); const router = useRouter(); From e987712287753c976988efcf85e274a32af571f2 Mon Sep 17 00:00:00 2001 From: ladunjexa <110838700+ladunjexa@users.noreply.github.com> Date: Wed, 1 Nov 2023 06:19:54 +0200 Subject: [PATCH 13/45] Refactored `Filter` component layout and styling for better responsiveness and dynamic spacing. Added `env.example` file --- .env.example | 19 +++++++++++++++++++ components/shared/Filters.tsx | 8 +++++--- 2 files changed, 24 insertions(+), 3 deletions(-) create mode 100644 .env.example diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..0f22e09 --- /dev/null +++ b/.env.example @@ -0,0 +1,19 @@ +NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY= +CLERK_SECRET_KEY= + +CLERK_WEBHOOK_SECRET= + +NEXT_PUBLIC_CLERK_SIGN_IN_URL=/sign-in +NEXT_PUBLIC_CLERK_SIGN_UP_URL=/sign-up +NEXT_PUBLIC_CLERK_AFTER_SIGN_IN_URL=/ +NEXT_PUBLIC_CLERK_AFTER_SIGN_UP_URL=/onboarding + +NEXT_PUBLIC_TINY_MCE_API_KEY= + +MONGODB_URL= + +NEXT_PUBLIC_SERVER_URL= + +OPENAI_API_KEY= + +RAPID_API_KEY= diff --git a/components/shared/Filters.tsx b/components/shared/Filters.tsx index 4193039..b12acfa 100644 --- a/components/shared/Filters.tsx +++ b/components/shared/Filters.tsx @@ -47,8 +47,8 @@ const Filters = ({ }; return ( -
- {filters.map((filter) => ( +
+ {filters.map((filter, index) => ( From fde73fd45b61071b8701eb2a0df1b1721584a174 Mon Sep 17 00:00:00 2001 From: ladunjexa <110838700+ladunjexa@users.noreply.github.com> Date: Wed, 1 Nov 2023 06:38:45 +0200 Subject: [PATCH 14/45] Modified `Filter` component styles and visibility handling --- components/shared/Filters.tsx | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/components/shared/Filters.tsx b/components/shared/Filters.tsx index b12acfa..7c6c20a 100644 --- a/components/shared/Filters.tsx +++ b/components/shared/Filters.tsx @@ -47,17 +47,20 @@ const Filters = ({ }; return ( -
+
{filters.map((filter, index) => (

AltStyle によって変換されたページ (->オリジナル) /