├── .eslintrc.json ├── public ├── og.png ├── logo.png ├── favicon.ico ├── talents.png └── vercel.svg ├── helpers ├── fetcher.js ├── fetch.js ├── capitalize.js ├── getActualPlace.js ├── checkIfThisWeek.js ├── query.js ├── getKeywordsFromSnippet.js ├── slugify.js ├── notifyTelegram.js ├── getHtmlSPA.js ├── addContactToList.js ├── constructUrlQuery.js └── extractJobDetails.js ├── constants ├── sites.js ├── SEO.js └── paths.js ├── pages ├── api │ ├── hello.js │ ├── devs │ │ ├── index.js │ │ └── [id].js │ ├── me.js │ ├── jobs.js │ ├── users.js │ ├── unsubscribe.js │ ├── auth │ │ └── [[...path]].js │ ├── supertokens │ │ └── change-password.js │ ├── alerts.js │ ├── fetch.js │ └── og.jsx ├── auth │ └── [[...path]].js ├── _document.js ├── emails │ └── unsubscribe.js ├── _app.js ├── thank-you │ └── [id].js ├── pricing.js ├── account.js ├── [tech] │ └── [location].js ├── index.js ├── stats.js ├── talents │ ├── index.js │ └── [id].js ├── jobs │ ├── index.js │ └── [slug].js └── profile.js ├── config ├── appInfo.js ├── frontendConfig.js └── backendConfig.js ├── types └── jobs.js ├── .env.sample ├── icons ├── ClockIcon.js ├── PinIcon.js ├── CashIcon.js ├── BriefcaseIcon.js └── CalendarIcon.js ├── .gitignore ├── libs ├── mongoose.js └── mongo.js ├── context └── user.js ├── components ├── CariKabel.js ├── SectionContainer.js ├── FilterDesktop.js ├── GlobalFooter.js ├── ReminderBanner.js ├── FilterCard.js ├── ChakraMarkdown.js ├── TechIcon.js ├── FlagIcon.js ├── JobListing.js ├── Filters.js └── GlobalHeader.js ├── package.json ├── schemas └── User.js ├── README.md ├── controllers ├── users.js └── jobs.js └── next.config.js /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "next/core-web-vitals" 3 | } 4 | -------------------------------------------------------------------------------- /public/og.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/afrieirham/mytechjobs/HEAD/public/og.png -------------------------------------------------------------------------------- /helpers/fetcher.js: -------------------------------------------------------------------------------- 1 | export const fetcher = (url) => fetch(url).then((r) => r.json()); 2 | -------------------------------------------------------------------------------- /public/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/afrieirham/mytechjobs/HEAD/public/logo.png -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/afrieirham/mytechjobs/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /public/talents.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/afrieirham/mytechjobs/HEAD/public/talents.png -------------------------------------------------------------------------------- /helpers/fetch.js: -------------------------------------------------------------------------------- 1 | import fetch from "node-fetch"; 2 | 3 | export const getUrlBody = async (url) => fetch(url).then((res) => res.text()); 4 | -------------------------------------------------------------------------------- /constants/sites.js: -------------------------------------------------------------------------------- 1 | export const sites = [ 2 | "workable.com", 3 | "briohr.com", 4 | "greenhouse.io", 5 | "lever.co", 6 | "hiredly.com", 7 | "maukerja.my", 8 | ]; 9 | -------------------------------------------------------------------------------- /constants/SEO.js: -------------------------------------------------------------------------------- 1 | import { sites } from "./sites"; 2 | 3 | export const siteDescription = `Kerja IT (kerja-it.com) | Job board that source jobs daily from sites like ${sites.join( 4 | ", " 5 | )}`; 6 | -------------------------------------------------------------------------------- /helpers/capitalize.js: -------------------------------------------------------------------------------- 1 | export const capitalize = (str) => 2 | str 3 | .toLowerCase() 4 | .split(" ") 5 | .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) 6 | .join(" "); 7 | -------------------------------------------------------------------------------- /pages/api/hello.js: -------------------------------------------------------------------------------- 1 | // Next.js API route support: https://nextjs.org/docs/api-routes/introduction 2 | 3 | export default async function handler(req, res) { 4 | res.status(200).json({ name: "John Doe" }); 5 | } 6 | -------------------------------------------------------------------------------- /config/appInfo.js: -------------------------------------------------------------------------------- 1 | export const appInfo = { 2 | // learn more about this on https://supertokens.com/docs/thirdpartyemailpassword/appinfo 3 | appName: "kerja-it.com", 4 | apiBasePath: "/api/auth", 5 | websiteBasePath: "/auth", 6 | }; 7 | -------------------------------------------------------------------------------- /pages/api/devs/index.js: -------------------------------------------------------------------------------- 1 | import { getPublicDevelopers } from "../../../controllers/users"; 2 | 3 | export default async function handler(req, res) { 4 | const [devs, error] = await getPublicDevelopers(); 5 | res.status(200).json({ devs, error }); 6 | } 7 | -------------------------------------------------------------------------------- /helpers/getActualPlace.js: -------------------------------------------------------------------------------- 1 | export const getActualPlace = (place) => { 2 | switch (place) { 3 | case "ns": 4 | return "negeri-sembilan"; 5 | case "kl": 6 | return "kuala-lumpur"; 7 | default: 8 | return place; 9 | } 10 | }; 11 | -------------------------------------------------------------------------------- /pages/api/devs/[id].js: -------------------------------------------------------------------------------- 1 | import { getPublicDeveloperById } from "../../../controllers/users"; 2 | 3 | export default async function handler(req, res) { 4 | const [dev, error] = await getPublicDeveloperById(req.query.id); 5 | res.status(200).json({ dev, error }); 6 | } 7 | -------------------------------------------------------------------------------- /helpers/checkIfThisWeek.js: -------------------------------------------------------------------------------- 1 | import { differenceInDays } from "date-fns"; 2 | 3 | export const checkIfThisWeek = (someDate) => { 4 | // const result = formatDistanceToNow(new Date(someDate)); 5 | const result = differenceInDays(new Date(), new Date(someDate)); 6 | 7 | return result < 8; 8 | }; 9 | -------------------------------------------------------------------------------- /types/jobs.js: -------------------------------------------------------------------------------- 1 | export const JOB_EXPERIENCE_TEXT = { 2 | 1: "0 to 1 year", 3 | 2: "1 to 3 years", 4 | 3: "3 to 5 years", 5 | 4: "5 to 10 years", 6 | 5: "10+ years", 7 | }; 8 | 9 | export const JOB_TYPE_TEXT = { 10 | 1: "Full time", 11 | 2: "Part time", 12 | 3: "Internship", 13 | 4: "Contract", 14 | }; 15 | -------------------------------------------------------------------------------- /helpers/query.js: -------------------------------------------------------------------------------- 1 | export const extractQuery = ({ tech, location }) => ({ 2 | tech: standardizeQuery(tech), 3 | location: standardizeQuery(location), 4 | }); 5 | 6 | export const standardizeQuery = (params) => { 7 | if (!params) { 8 | return null; 9 | } 10 | 11 | if (!Array.isArray(params)) { 12 | return [params]; 13 | } 14 | 15 | return params; 16 | }; 17 | -------------------------------------------------------------------------------- /pages/api/me.js: -------------------------------------------------------------------------------- 1 | import { getUserBySessionId } from "../../controllers/users"; 2 | 3 | export default async function handler(req, res) { 4 | const id = req?.query?.id; 5 | 6 | if (!id) { 7 | return res.status(204); 8 | } 9 | 10 | const raw = await getUserBySessionId(id); 11 | const data = JSON.parse(JSON.stringify(raw.user)); 12 | 13 | return res.json({ name: data.name, status: data.status }); 14 | } 15 | -------------------------------------------------------------------------------- /helpers/getKeywordsFromSnippet.js: -------------------------------------------------------------------------------- 1 | import { load } from "cheerio"; 2 | 3 | export const getKeywordsFromSnippet = (snippet) => { 4 | const $ = load(snippet); 5 | const keywords = $("b").toArray(); 6 | 7 | let raw = []; 8 | 9 | keywords.forEach((keyword) => { 10 | raw.push(keyword.children[0].data); 11 | }); 12 | 13 | const clean = raw 14 | .filter((text) => text !== "...") 15 | .map((t) => t.toLowerCase()); 16 | 17 | return [...new Set(clean)]; 18 | }; 19 | -------------------------------------------------------------------------------- /.env.sample: -------------------------------------------------------------------------------- 1 | NEXT_PUBLIC_SUBSCRIPTION_LINK="" 2 | 3 | # telegram 4 | TELEGRAM_HTTP_TOKEN="" 5 | TELEGRAM_CHAT_ID_ADMIN="" 6 | TELEGRAM_CHAT_ID="" 7 | 8 | # database 9 | MONGO_DB_URI="" 10 | MONGO_DB_NAME="" 11 | 12 | # emails 13 | SMTP_HOST="" 14 | SMTP_PORT="" 15 | SMTP_EMAIL="" 16 | SMTP_PASSWORD="" 17 | 18 | # sendinblie 19 | SENDINBLUE_API_KEY="" 20 | 21 | # security 22 | ALERT_SECRET="" 23 | UNSUBSCRIPTION_SECRET="" 24 | 25 | # auth 26 | SUPERTOKENS_CONNECTION_URI="" 27 | SUPERTOKENS_API_KEY="" 28 | SUPERTOKENS_DASHBOARD_KEY="" 29 | -------------------------------------------------------------------------------- /icons/ClockIcon.js: -------------------------------------------------------------------------------- 1 | import { Icon } from "@chakra-ui/react"; 2 | 3 | export default function ClockIcon(props) { 4 | return ( 5 | 14 | 15 | 16 | 17 | 18 | ); 19 | } 20 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | *.pem 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | .pnpm-debug.log* 27 | 28 | # local env files 29 | .env*.local 30 | 31 | # vercel 32 | .vercel 33 | 34 | # typescript 35 | *.tsbuildinfo 36 | next-env.d.ts 37 | -------------------------------------------------------------------------------- /helpers/slugify.js: -------------------------------------------------------------------------------- 1 | import { nanoid } from "nanoid"; 2 | 3 | const toSlug = (str) => 4 | str 5 | .toLowerCase() 6 | .trim() 7 | .replaceAll(/[^a-zA-Z0-9 ]/g, "") 8 | .replaceAll(" ", "-"); 9 | 10 | export const slugify = (job) => { 11 | const title = job?.schema?.title ? job.schema.title : job.title; 12 | const company = job?.schema?.hiringOrganization?.name; 13 | 14 | if (!Boolean(company)) { 15 | return toSlug(title) + "-" + nanoid(4); 16 | } 17 | 18 | return toSlug(title) + "-" + toSlug(company) + "-" + nanoid(4); 19 | }; 20 | -------------------------------------------------------------------------------- /icons/PinIcon.js: -------------------------------------------------------------------------------- 1 | import { Icon } from "@chakra-ui/react"; 2 | 3 | export default function PinIcon(props) { 4 | return ( 5 | 14 | 15 | 16 | 17 | 18 | ); 19 | } 20 | -------------------------------------------------------------------------------- /helpers/notifyTelegram.js: -------------------------------------------------------------------------------- 1 | import queryString from "query-string"; 2 | import fetch from "node-fetch"; 3 | 4 | export const notifyTelegram = (text, isCustomer = false) => { 5 | const token = process.env.TELEGRAM_HTTP_TOKEN; 6 | const chat_id = isCustomer 7 | ? process.env.TELEGRAM_CHAT_ID 8 | : process.env.TELEGRAM_CHAT_ID_ADMIN; 9 | 10 | // send telegram notification 11 | const url = `https://api.telegram.org/bot${token}/sendMessage`; 12 | const telegramUrl = queryString.stringifyUrl({ 13 | url, 14 | query: { chat_id, text }, 15 | }); 16 | return fetch(telegramUrl); 17 | }; 18 | -------------------------------------------------------------------------------- /libs/mongoose.js: -------------------------------------------------------------------------------- 1 | import mongoose from "mongoose"; 2 | 3 | const URI = process.env.MONGO_DB_URI; 4 | const DB = process.env.MONGO_DB_NAME; 5 | 6 | const uri = `${URI}/${DB}`; 7 | 8 | let cached = global.mongoose; 9 | 10 | if (!cached) { 11 | cached = global.mongoose = { conn: null, promise: null }; 12 | } 13 | 14 | export const connect = async () => { 15 | if (cached.conn) { 16 | return cached.conn; 17 | } 18 | 19 | if (!cached.promise) { 20 | cached.promise = mongoose.connect(uri).then((mongoose) => mongoose); 21 | } 22 | cached.conn = await cached.promise; 23 | return cached.conn; 24 | }; 25 | -------------------------------------------------------------------------------- /constants/paths.js: -------------------------------------------------------------------------------- 1 | export const places = [ 2 | "remote", 3 | "kuala-lumpur", 4 | "selangor", 5 | "putrajaya", 6 | "johor", 7 | "kedah", 8 | "kelantan", 9 | "melaka", 10 | "negeri-sembilan", 11 | "pahang", 12 | "perak", 13 | "perlis", 14 | "pulau-pinang", 15 | "sarawak", 16 | "terengganu", 17 | "labuan", 18 | "sabah", 19 | ]; 20 | 21 | export const frameworks = [ 22 | "react", 23 | "react-js", 24 | "react-native", 25 | "vue", 26 | "angular", 27 | "node-js", 28 | "laravel", 29 | "flutter", 30 | "django", 31 | "kotlin", 32 | "ruby-on-rails", 33 | "webflow", 34 | ]; 35 | -------------------------------------------------------------------------------- /context/user.js: -------------------------------------------------------------------------------- 1 | import useSWR from "swr"; 2 | import { createContext } from "react"; 3 | import { useSessionContext } from "supertokens-auth-react/recipe/session"; 4 | 5 | import { fetcher } from "../helpers/fetcher"; 6 | 7 | export const UserContext = createContext({}); 8 | 9 | export function UserProvider({ children }) { 10 | const { userId } = useSessionContext(); 11 | const { data } = useSWR(userId ? "/api/me?id=" + userId : "", fetcher); 12 | 13 | return ( 14 | 15 | {children} 16 | 17 | ); 18 | } 19 | -------------------------------------------------------------------------------- /helpers/getHtmlSPA.js: -------------------------------------------------------------------------------- 1 | import puppeteer from "puppeteer"; 2 | 3 | export const getHtmlSPA = async (url) => { 4 | const browser = await puppeteer.launch({ headless: true }); 5 | const page = await browser.newPage(); 6 | await page.goto(url); 7 | 8 | try { 9 | await page.waitForSelector("script[type='application/ld+json']", { 10 | timeout: 1000 * 10, 11 | }); 12 | } catch (error) { 13 | return null; 14 | } 15 | 16 | const bodyHandle = await page.$("html"); 17 | const html = await page.evaluate((body) => body.innerHTML, bodyHandle); 18 | await browser.close(); 19 | 20 | return html; 21 | }; 22 | -------------------------------------------------------------------------------- /icons/CashIcon.js: -------------------------------------------------------------------------------- 1 | import { Icon } from "@chakra-ui/react"; 2 | 3 | export default function CashIcon(props) { 4 | return ( 5 | 14 | 15 | 16 | 17 | 18 | 19 | ); 20 | } 21 | -------------------------------------------------------------------------------- /components/CariKabel.js: -------------------------------------------------------------------------------- 1 | import { Box, Link, Stack, Text } from "@chakra-ui/react"; 2 | 3 | export default function Slides() { 4 | return ( 5 | 6 | 13 | 14 | Find more remote jobs at{" "} 15 | 16 | Kerja-Remote.com 17 | 18 | 19 | 20 | 21 | ); 22 | } 23 | -------------------------------------------------------------------------------- /icons/BriefcaseIcon.js: -------------------------------------------------------------------------------- 1 | import { Icon } from "@chakra-ui/react"; 2 | 3 | export default function BriefcaseIcon(props) { 4 | return ( 5 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | ); 21 | } 22 | -------------------------------------------------------------------------------- /helpers/addContactToList.js: -------------------------------------------------------------------------------- 1 | import axios from "axios"; 2 | 3 | export const addContactToList = async ({ name, email }) => { 4 | // https://developers.sendinblue.com/reference/createcontact 5 | const baseUrl = "https://api.sendinblue.com/v3/contacts"; 6 | 7 | try { 8 | const config = { headers: { "api-key": process.env.SENDINBLUE_API_KEY } }; 9 | const redBody = { 10 | email: email, 11 | listIds: [9], 12 | attributes: { FIRSTNAME: name }, 13 | updateEnabled: true, 14 | }; 15 | 16 | await axios.post(baseUrl, redBody, config); 17 | 18 | return true; 19 | } catch (error) { 20 | console.log(error); 21 | return false; 22 | } 23 | }; 24 | -------------------------------------------------------------------------------- /pages/auth/[[...path]].js: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from "react"; 2 | import dynamic from "next/dynamic"; 3 | import SuperTokens from "supertokens-auth-react"; 4 | import { redirectToAuth } from "supertokens-auth-react"; 5 | 6 | const SuperTokensComponentNoSSR = dynamic( 7 | new Promise((res) => res(SuperTokens.getRoutingComponent)), 8 | { ssr: false } 9 | ); 10 | 11 | export default function Auth() { 12 | // if the user visits a page that is not handled by us (like /auth/random), then we redirect them back to the auth page. 13 | useEffect(() => { 14 | if (SuperTokens.canHandleRoute() === false) { 15 | redirectToAuth(); 16 | } 17 | }, []); 18 | 19 | return ; 20 | } 21 | -------------------------------------------------------------------------------- /components/SectionContainer.js: -------------------------------------------------------------------------------- 1 | import { Flex, Stack } from "@chakra-ui/react"; 2 | import React from "react"; 3 | 4 | function SectionContainer({ children, outerContainerProps, ...props }) { 5 | return ( 6 | 18 | 19 | {children} 20 | 21 | 22 | ); 23 | } 24 | 25 | export default SectionContainer; 26 | -------------------------------------------------------------------------------- /icons/CalendarIcon.js: -------------------------------------------------------------------------------- 1 | import { Icon } from "@chakra-ui/react"; 2 | 3 | export default function CalendarIcon(props) { 4 | return ( 5 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | ); 23 | } 24 | -------------------------------------------------------------------------------- /pages/api/jobs.js: -------------------------------------------------------------------------------- 1 | import { getJobsJSON, getFeaturedJobs } from "../../controllers/jobs"; 2 | import { extractQuery, standardizeQuery } from "../../helpers/query"; 3 | 4 | export default async function handler(req, res) { 5 | const { method, query } = req; 6 | 7 | if (method !== "GET") { 8 | return res.status(405).json({ status: "not allowed" }); 9 | } 10 | 11 | const { page, sortBy, tech, location, jobType } = query; 12 | const { jobs } = await getJobsJSON({ 13 | page, 14 | sortBy, 15 | tech: standardizeQuery(tech), 16 | location: standardizeQuery(location), 17 | jobType: standardizeQuery(jobType), 18 | }); 19 | const { featured } = await getFeaturedJobs(); 20 | 21 | return res.status(200).json({ jobs, featured }); 22 | } 23 | -------------------------------------------------------------------------------- /components/FilterDesktop.js: -------------------------------------------------------------------------------- 1 | import { Flex, Heading } from "@chakra-ui/react"; 2 | import React from "react"; 3 | import Filters from "./Filters"; 4 | 5 | function FilterDesktop(props) { 6 | return ( 7 | 16 | 25 | 26 | 27 | 28 | ); 29 | } 30 | 31 | export default FilterDesktop; 32 | -------------------------------------------------------------------------------- /pages/api/users.js: -------------------------------------------------------------------------------- 1 | import { updateUserMetadata } from "supertokens-node/recipe/usermetadata"; 2 | import { updateProfile } from "../../controllers/users"; 3 | 4 | export default async function handler(req, res) { 5 | const { method, body } = req; 6 | 7 | if (method !== "POST") { 8 | return res.status(405).json({ status: "not allowed" }); 9 | } 10 | 11 | // verify valid request 12 | if (!body) { 13 | return res.status(400).json({ status: "no body" }); 14 | } 15 | 16 | const [success, error] = await updateProfile(body); 17 | if (error) { 18 | return res.status(400).json({ error, msg: "something went wrong" }); 19 | } 20 | 21 | await updateUserMetadata(body.superTokensId, { first_name: body.name }); 22 | 23 | return res.json({ msg: "profile updated", status: success }); 24 | } 25 | -------------------------------------------------------------------------------- /components/GlobalFooter.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Flex, HStack, Link, Text } from "@chakra-ui/react"; 3 | 4 | function GlobalFooter() { 5 | const year = new Date().getFullYear(); 6 | return ( 7 | 16 | 17 | Copyright {year} Kerja IT 18 | 19 | Open-Source 20 | 21 | 22 | 23 | 24 | 25 | Email 26 | 27 | 28 | Twitter 29 | 30 | 31 | 32 | ); 33 | } 34 | 35 | export default GlobalFooter; 36 | -------------------------------------------------------------------------------- /pages/api/unsubscribe.js: -------------------------------------------------------------------------------- 1 | import axios from "axios"; 2 | import Cryptr from "cryptr"; 3 | 4 | const WEEKLY_JOB_ALERTS = 3; 5 | const baseUrl = `https://api.sendinblue.com/v3/contacts/lists/${WEEKLY_JOB_ALERTS}/contacts/remove`; 6 | 7 | export default async function handler(req, res) { 8 | const { method, body } = req; 9 | 10 | if (method !== "POST") { 11 | return res.status(405).json({ status: "not allowed" }); 12 | } 13 | 14 | const cryptr = new Cryptr(process.env.UNSUBSCRIPTION_SECRET); 15 | const email = cryptr.decrypt(body.token); 16 | 17 | try { 18 | const config = { headers: { "api-key": process.env.SENDINBLUE_API_KEY } }; 19 | const redBody = { emails: [email] }; 20 | 21 | // https://developers.sendinblue.com/reference/removecontactfromlist 22 | await axios.post(baseUrl, redBody, config); 23 | 24 | return res.status(200).json({ message: "success" }); 25 | } catch (error) { 26 | return res.status(200).json({ message: error.response.data.message }); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /libs/mongo.js: -------------------------------------------------------------------------------- 1 | import { MongoClient } from "mongodb"; 2 | 3 | const uri = process.env.MONGO_DB_URI; 4 | const dbName = process.env.MONGO_DB_NAME; 5 | const options = { 6 | useUnifiedTopology: true, 7 | useNewUrlParser: true, 8 | }; 9 | 10 | let mongoClient = null; 11 | let database = null; 12 | 13 | if (!uri) { 14 | throw new Error("Please add your Mongo URI to .env.local"); 15 | } 16 | 17 | export async function connectToDatabase() { 18 | try { 19 | if (mongoClient && database) { 20 | return { mongoClient, db: database }; 21 | } 22 | if (process.env.NODE_ENV === "development") { 23 | if (!global._mongoClient) { 24 | mongoClient = await new MongoClient(uri, options).connect(); 25 | global._mongoClient = mongoClient; 26 | } else { 27 | mongoClient = global._mongoClient; 28 | } 29 | } else { 30 | mongoClient = await new MongoClient(uri, options).connect(); 31 | } 32 | database = await mongoClient.db(dbName); 33 | return { mongoClient, db: database }; 34 | } catch (e) { 35 | console.error(e); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /public/vercel.svg: -------------------------------------------------------------------------------- 1 | 3 | 4 | -------------------------------------------------------------------------------- /pages/_document.js: -------------------------------------------------------------------------------- 1 | import { Html, Head, Main, NextScript } from "next/document"; 2 | 3 | export default function Document() { 4 | return ( 5 | 6 | 7 | 8 | 13 | 17 | 22 | 27 | 28 | 29 |
30 | 31 | 32 | 33 | ); 34 | } 35 | -------------------------------------------------------------------------------- /helpers/constructUrlQuery.js: -------------------------------------------------------------------------------- 1 | import { format, sub } from "date-fns"; 2 | 3 | export const constructUrlQuery = () => { 4 | const time = `after:${format(sub(new Date(), { days: 1 }), "yyyy-MM-dd")}`; 5 | 6 | const location = constructLocationQuery([ 7 | "Johor", 8 | "Kedah", 9 | "Kelantan", 10 | "Melaka", 11 | "Negeri Sembilan", 12 | "Pahang", 13 | "Perak", 14 | "Perlis", 15 | "Pulau Pinang", 16 | "Sarawak", 17 | "Selangor", 18 | "Terengganu", 19 | "Kuala Lumpur", 20 | "Labuan", 21 | "Sabah", 22 | "Putrajaya", 23 | "Malaysia", 24 | ]); 25 | 26 | const terms = constructLocationQuery([ 27 | "react js", 28 | "react native", 29 | "vue", 30 | "angular", 31 | "node js", 32 | "laravel", 33 | "flutter", 34 | "django", 35 | "kotlin", 36 | "webflow", 37 | ]); 38 | 39 | const query = `${time} ${terms} ${location}`; 40 | 41 | return query; 42 | }; 43 | 44 | const constructLocationQuery = (locations) => { 45 | const lQuery = locations 46 | .map((location) => `"${location.toLowerCase()}"`) 47 | .join(" | "); 48 | return `(${lQuery})`; 49 | }; 50 | -------------------------------------------------------------------------------- /config/frontendConfig.js: -------------------------------------------------------------------------------- 1 | import Router from "next/router"; 2 | import EmailPasswordReact from "supertokens-auth-react/recipe/emailpassword"; 3 | import SessionReact from "supertokens-auth-react/recipe/session"; 4 | 5 | import { appInfo } from "./appInfo"; 6 | 7 | export const frontendConfig = () => { 8 | return { 9 | appInfo: { 10 | ...appInfo, 11 | apiDomain: window.location.origin, 12 | websiteDomain: window.location.origin, 13 | }, 14 | recipeList: [ 15 | SessionReact.init(), 16 | EmailPasswordReact.init({ 17 | signInAndUpFeature: { 18 | defaultToSignUp: true, 19 | signUpForm: { 20 | formFields: [ 21 | { 22 | id: "name", 23 | label: "Full name", 24 | placeholder: "e.g. Ahmad Farah", 25 | }, 26 | ], 27 | }, 28 | }, 29 | }), 30 | ], 31 | windowHandler: (oI) => { 32 | return { 33 | ...oI, 34 | location: { 35 | ...oI.location, 36 | setHref: (href) => { 37 | Router.push(href); 38 | }, 39 | }, 40 | }; 41 | }, 42 | }; 43 | }; 44 | -------------------------------------------------------------------------------- /pages/api/auth/[[...path]].js: -------------------------------------------------------------------------------- 1 | import NextCors from "nextjs-cors"; 2 | import supertokens from "supertokens-node"; 3 | import { middleware } from "supertokens-node/framework/express"; 4 | import { superTokensNextWrapper } from "supertokens-node/nextjs"; 5 | 6 | import { backendConfig } from "../../../config/backendConfig"; 7 | 8 | supertokens.init(backendConfig()); 9 | 10 | export default async function superTokens(req, res) { 11 | // NOTE: We need CORS only if we are querying the APIs from a different origin 12 | await NextCors(req, res, { 13 | methods: ["GET", "HEAD", "PUT", "PATCH", "POST", "DELETE"], 14 | origin: process.env.NEXT_PUBLIC_DOMAIN_NAME, 15 | credentials: true, 16 | allowedHeaders: ["content-type", ...supertokens.getAllCORSHeaders()], 17 | }); 18 | 19 | await superTokensNextWrapper( 20 | async (next) => { 21 | // This is needed for production deployments with Vercel 22 | res.setHeader( 23 | "Cache-Control", 24 | "no-cache, no-store, max-age=0, must-revalidate" 25 | ); 26 | await middleware()(req, res, next); 27 | }, 28 | req, 29 | res 30 | ); 31 | if (!res.writableEnded) { 32 | res.status(404).send("Not found"); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /components/ReminderBanner.js: -------------------------------------------------------------------------------- 1 | import React, { useContext } from "react"; 2 | import NextLink from "next/link"; 3 | import { useSessionContext } from "supertokens-auth-react/recipe/session"; 4 | import { Box, Button, Stack, Text } from "@chakra-ui/react"; 5 | 6 | import { UserContext } from "../context/user"; 7 | 8 | function ReminderBanner() { 9 | const { doesSessionExist } = useSessionContext(); 10 | const { status } = useContext(UserContext); 11 | 12 | const isVisible = ["active", "open"].some((s) => s === status); 13 | const show = doesSessionExist && !isVisible; 14 | 15 | if (!show) { 16 | return null; 17 | } 18 | 19 | return ( 20 | 21 | 28 | 29 | 🚨 Your profile is invisible – employers can't find you. 30 | 31 | 34 | 35 | 36 | ); 37 | } 38 | 39 | export default ReminderBanner; 40 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "mytechjobs", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start", 9 | "start-do": "next start -p 8000", 10 | "lint": "next lint" 11 | }, 12 | "dependencies": { 13 | "@chakra-ui/react": "^2.4.9", 14 | "@emotion/react": "^11.10.5", 15 | "@emotion/styled": "^11.10.5", 16 | "@vercel/analytics": "^0.1.8", 17 | "@vercel/og": "^0.0.27", 18 | "axios": "^1.3.2", 19 | "chart.js": "^4.3.2", 20 | "cheerio": "^1.0.0-rc.12", 21 | "cryptr": "^6.1.0", 22 | "date-fns": "^2.29.3", 23 | "eslint": "8.29.0", 24 | "eslint-config-next": "13.0.6", 25 | "framer-motion": "^8.5.0", 26 | "mongodb": "^4.12.1", 27 | "mongoose": "^6.9.2", 28 | "nanoid": "^4.0.0", 29 | "next": "13.0.6", 30 | "nextjs-cors": "^2.1.2", 31 | "node-fetch": "^3.3.0", 32 | "nodemailer": "^6.9.1", 33 | "puppeteer": "^19.4.0", 34 | "query-string": "^8.1.0", 35 | "react": "18.2.0", 36 | "react-chartjs-2": "^5.2.0", 37 | "react-dom": "18.2.0", 38 | "react-icons": "^4.7.1", 39 | "react-markdown": "^8.0.5", 40 | "supertokens-auth-react": "^0.31.1", 41 | "supertokens-node": "^13.0.2", 42 | "supertokens-web-js": "^0.5.0", 43 | "swr": "^2.0.1", 44 | "unescape": "^1.0.1" 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /schemas/User.js: -------------------------------------------------------------------------------- 1 | import { Schema, model, models } from "mongoose"; 2 | 3 | const userSchema = Schema( 4 | { 5 | headline: { 6 | type: String, 7 | }, 8 | name: { 9 | type: String, 10 | required: true, 11 | }, 12 | email: { 13 | type: String, 14 | required: true, 15 | unique: true, 16 | }, 17 | phone: { 18 | type: String, 19 | }, 20 | city: { 21 | type: String, 22 | }, 23 | state: { 24 | type: String, 25 | }, 26 | bio: { 27 | type: String, 28 | }, 29 | positions: { 30 | type: Array, 31 | }, 32 | status: { 33 | type: String, 34 | required: true, 35 | default: "invisible", 36 | }, 37 | jobTypes: { 38 | type: Array, 39 | }, 40 | jobLevels: { 41 | type: Array, 42 | }, 43 | arrangements: { 44 | type: Array, 45 | }, 46 | locations: { 47 | type: Array, 48 | }, 49 | availableDate: { 50 | type: Date, 51 | }, 52 | website: { 53 | type: String, 54 | }, 55 | github: { 56 | type: String, 57 | }, 58 | linkedin: { 59 | type: String, 60 | }, 61 | jobAlerts: { 62 | type: Boolean, 63 | required: true, 64 | default: false, 65 | }, 66 | superTokensId: { 67 | type: String, 68 | required: true, 69 | unique: true, 70 | }, 71 | }, 72 | { timestamps: true } 73 | ); 74 | 75 | export const User = models?.User || model("User", userSchema); 76 | -------------------------------------------------------------------------------- /components/FilterCard.js: -------------------------------------------------------------------------------- 1 | import { 2 | Button, 3 | Drawer, 4 | DrawerBody, 5 | DrawerCloseButton, 6 | DrawerContent, 7 | DrawerHeader, 8 | DrawerOverlay, 9 | Heading, 10 | useDisclosure, 11 | } from "@chakra-ui/react"; 12 | import React from "react"; 13 | 14 | import Filters from "./Filters"; 15 | 16 | function FilterCard(props) { 17 | const { isOpen, onOpen, onClose } = useDisclosure(); 18 | 19 | return ( 20 | <> 21 | 34 | 35 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | ); 61 | } 62 | 63 | export default FilterCard; 64 | -------------------------------------------------------------------------------- /pages/emails/unsubscribe.js: -------------------------------------------------------------------------------- 1 | import React, { useState } from "react"; 2 | import axios from "axios"; 3 | 4 | import { useRouter } from "next/router"; 5 | import { Button, Flex, Heading, Tag, Text } from "@chakra-ui/react"; 6 | 7 | function Unsubscribe() { 8 | const [loading, setLoading] = useState(false); 9 | const router = useRouter(); 10 | const { token, email } = router.query; 11 | 12 | const onUnsubscribe = async () => { 13 | setLoading(true); 14 | await axios.post("/api/unsubscribe", { token }); 15 | alert("Success"); 16 | router.push("/"); 17 | }; 18 | 19 | return ( 20 | 30 | 31 | Thank you being an awesome subscriber! 32 | 33 | 34 | We hope you've landed a job and don't need this anymore. 🥳 35 | 36 | 37 | Unsubscribe for:{" "} 38 | 39 | {email} 40 | 41 | 42 | 52 | 53 | ); 54 | } 55 | 56 | export default Unsubscribe; 57 | -------------------------------------------------------------------------------- /pages/_app.js: -------------------------------------------------------------------------------- 1 | import { Analytics } from "@vercel/analytics/react"; 2 | import { ChakraProvider, extendTheme } from "@chakra-ui/react"; 3 | import SuperTokensReact, { SuperTokensWrapper } from "supertokens-auth-react"; 4 | 5 | import { UserProvider } from "../context/user"; 6 | import { frontendConfig } from "../config/frontendConfig"; 7 | import GlobalFooter from "../components/GlobalFooter"; 8 | import GlobalHeader from "../components/GlobalHeader"; 9 | import ReminderBanner from "../components/ReminderBanner"; 10 | import CariKabel from "../components/CariKabel"; 11 | 12 | if (typeof window !== "undefined") { 13 | // we only want to call this init function on the frontend, so we check typeof window !== 'undefined' 14 | SuperTokensReact.init(frontendConfig()); 15 | } 16 | 17 | function MyApp({ Component, pageProps }) { 18 | return ( 19 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | ); 46 | } 47 | 48 | export default MyApp; 49 | -------------------------------------------------------------------------------- /pages/thank-you/[id].js: -------------------------------------------------------------------------------- 1 | import { Box, Flex, Heading, Link, Text } from "@chakra-ui/react"; 2 | import Head from "next/head"; 3 | import React from "react"; 4 | 5 | import { siteDescription } from "../../constants/SEO"; 6 | 7 | function ThankYou() { 8 | const title = "Thank You For You Purchase 🥳 | Kerja IT"; 9 | return ( 10 | 11 | 12 | {title} 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 30 | Thank You For You Purchase 🥳 31 | 32 | ✍️ Posting job listing are still in development. We will let you know 33 | when it goes live within 24 to 48 hours. 34 | 35 | 36 | If you have any questions, feel free to contact me at{" "} 37 | 38 | admin@kerja-it.com 39 | 40 | 41 | 42 | 43 | ); 44 | } 45 | 46 | export default ThankYou; 47 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app). 2 | 3 | ## Getting Started 4 | 5 | First, run the development server: 6 | 7 | ```bash 8 | npm run dev 9 | # or 10 | yarn dev 11 | ``` 12 | 13 | Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. 14 | 15 | You can start editing the page by modifying `pages/index.js`. The page auto-updates as you edit the file. 16 | 17 | [API routes](https://nextjs.org/docs/api-routes/introduction) can be accessed on [http://localhost:3000/api/hello](http://localhost:3000/api/hello). This endpoint can be edited in `pages/api/hello.js`. 18 | 19 | The `pages/api` directory is mapped to `/api/*`. Files in this directory are treated as [API routes](https://nextjs.org/docs/api-routes/introduction) instead of React pages. 20 | 21 | ## Learn More 22 | 23 | To learn more about Next.js, take a look at the following resources: 24 | 25 | - [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. 26 | - [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. 27 | 28 | You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome! 29 | 30 | ## Deploy on Vercel 31 | 32 | The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. 33 | 34 | Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details. 35 | -------------------------------------------------------------------------------- /pages/api/supertokens/change-password.js: -------------------------------------------------------------------------------- 1 | import { verifySession } from "supertokens-node/recipe/session/framework/express"; 2 | import { superTokensNextWrapper } from "supertokens-node/nextjs"; 3 | import { revokeAllSessionsForUser } from "supertokens-node/recipe/session"; 4 | import { 5 | getUserById, 6 | signIn, 7 | updateEmailOrPassword, 8 | } from "supertokens-node/recipe/emailpassword"; 9 | 10 | export default async function changePassword(req, res) { 11 | await superTokensNextWrapper( 12 | async (next) => { 13 | // This is needed for production deployments with Vercel 14 | res.setHeader( 15 | "Cache-Control", 16 | "no-cache, no-store, max-age=0, must-revalidate" 17 | ); 18 | await verifySession()(req, res, next); 19 | }, 20 | req, 21 | res 22 | ); 23 | 24 | const oldPassword = req.body.oldPassword; 25 | const updatedPassword = req.body.newPassword; 26 | 27 | const session = req.session; 28 | const userId = session.getUserId(); 29 | const userInfo = await getUserById(userId); 30 | 31 | if (userInfo === undefined) { 32 | throw new Error("Should never come here"); 33 | } 34 | 35 | const isPasswordValid = await signIn(userInfo.email, oldPassword); 36 | 37 | if (isPasswordValid.status !== "OK") { 38 | return res.status(400).json({ msg: "invalid password" }); 39 | } 40 | 41 | // update the user's password using updateEmailOrPassword 42 | const response = await updateEmailOrPassword({ 43 | userId, 44 | password: updatedPassword, 45 | }); 46 | 47 | if (response.status !== "OK") { 48 | return res.status(500).json({ msg: "user not found" }); 49 | } 50 | 51 | // revoke all sessions for the user 52 | await revokeAllSessionsForUser(userId); 53 | 54 | return res.status(200).json({ msg: "password updated" }); 55 | } 56 | -------------------------------------------------------------------------------- /controllers/users.js: -------------------------------------------------------------------------------- 1 | import { ObjectId } from "mongodb"; 2 | import { connect } from "../libs/mongoose"; 3 | import { User } from "../schemas/User"; 4 | 5 | export const createUser = async (data) => { 6 | await connect(); 7 | await User.create(data); 8 | }; 9 | 10 | export const getUserBySessionId = async (id) => { 11 | await connect(); 12 | const user = await User.findOne({ superTokensId: id }); 13 | if (!user) throw new Error("Invalid user"); 14 | return { user }; 15 | }; 16 | 17 | export const updateProfile = async (profile) => { 18 | await connect(); 19 | const { superTokensId } = profile; 20 | 21 | try { 22 | await User.findOneAndUpdate({ superTokensId }, profile); 23 | return [true, null]; 24 | } catch (error) { 25 | return [false, error]; 26 | } 27 | }; 28 | 29 | export const getPublicDevelopers = async () => { 30 | await connect(); 31 | 32 | try { 33 | const users = await User.aggregate([ 34 | { 35 | $match: { 36 | status: { 37 | $in: ["active", "open"], 38 | }, 39 | }, 40 | }, 41 | { $sort: { updatedAt: -1 } }, 42 | { $project: { headline: 1, bio: 1, status: 1, updatedAt: 1 } }, 43 | ]); 44 | 45 | return [users, null]; 46 | } catch (error) { 47 | return [null, error]; 48 | } 49 | }; 50 | 51 | export const getPublicDeveloperById = async (id) => { 52 | await connect(); 53 | 54 | try { 55 | const user = await User.aggregate([ 56 | { $match: { _id: ObjectId(id) } }, 57 | { $limit: 1 }, 58 | { 59 | $project: { 60 | bio: 1, 61 | status: 1, 62 | headline: 1, 63 | positions: 1, 64 | jobTypes: 1, 65 | jobLevels: 1, 66 | locations: 1, 67 | arrangements: 1, 68 | availableDate: 1, 69 | superTokensId: 1, 70 | updatedAt: 1, 71 | }, 72 | }, 73 | ]); 74 | 75 | return [user[0], null]; 76 | } catch (error) { 77 | return [null, error]; 78 | } 79 | }; 80 | -------------------------------------------------------------------------------- /helpers/extractJobDetails.js: -------------------------------------------------------------------------------- 1 | import fetch from "node-fetch"; 2 | import { load } from "cheerio"; 3 | import { getHtmlSPA } from "./getHtmlSPA"; 4 | import { getUrlBody } from "./fetch"; 5 | 6 | export const extractJobDetails = async (url) => { 7 | // Check if broken url 8 | const status = await fetch(url).then((res) => res.status); 9 | if (status >= 400) { 10 | return null; 11 | } 12 | 13 | // Fetch url content 14 | const response = await getUrlBody(url); 15 | 16 | // Get json-ld of the page 17 | const $ = load(response); 18 | const staticJobSchema = $("script[type='application/ld+json']").text(); 19 | 20 | if (staticJobSchema) { 21 | return { url, ...extract(staticJobSchema) }; 22 | } 23 | 24 | // // SPAs 25 | // const spaResponse = await getHtmlSPA(url); 26 | 27 | // if (!spaResponse.includes("JobPosting")) { 28 | // return null; 29 | // } 30 | 31 | // if (spaResponse) { 32 | // const spa$ = load(spaResponse); 33 | // const spajobSchema = spa$("script[type='application/ld+json']").text(); 34 | // return { url, ...extract(spajobSchema) }; 35 | // } 36 | 37 | // for briohr 38 | const jobTitle = $(".main-header .title-wrapper .title h1").text().trim(); 39 | const companyName = $(".company-name").text().trim(); 40 | const location = $(".location").text().trim(); 41 | const description = $(".description .wrapper span").html(); 42 | 43 | // no job schema 44 | const pageTitle = $("title").text(); 45 | const metaDescription = $("meta[name=description]").attr("content"); 46 | 47 | const title = jobTitle ? jobTitle : pageTitle; 48 | 49 | const manual = { 50 | url, 51 | title, 52 | companyName, 53 | location, 54 | description, 55 | metaDescription, 56 | }; 57 | 58 | return manual; 59 | }; 60 | 61 | const extract = (html) => { 62 | // Check if multiple json-ld 63 | const split = html.split("}{"); 64 | 65 | // Find jsob-ld for "JobPosting" 66 | if (split.length > 1) { 67 | const fixed = split.map((item, i) => 68 | i % 2 === 0 ? item + "}" : "{" + item 69 | ); 70 | const formatted = fixed.map((i) => JSON.parse(i)); 71 | const needed = formatted.find((item) => item["@type"] === "JobPosting"); 72 | return needed; 73 | } 74 | 75 | // Check if jsob-ld is "JobPosting" 76 | const needed = JSON.parse(html); 77 | if (needed["@type"] === "JobPosting") { 78 | return needed; 79 | } 80 | 81 | return null; 82 | }; 83 | -------------------------------------------------------------------------------- /components/ChakraMarkdown.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReactMarkdown from "react-markdown"; 3 | import { 4 | Divider, 5 | ListItem, 6 | OrderedList, 7 | Text, 8 | UnorderedList, 9 | } from "@chakra-ui/react"; 10 | 11 | function ChakraMarkdown({ children }) { 12 | return ( 13 | {children}; 17 | }, 18 | em({ children }) { 19 | return {children}; 20 | }, 21 | i({ children }) { 22 | return {children}; 23 | }, 24 | hr() { 25 | return ; 26 | }, 27 | text({ children }) { 28 | return {children}; 29 | }, 30 | ul({ children }) { 31 | return {children}; 32 | }, 33 | ol({ children }) { 34 | return {children}; 35 | }, 36 | li({ children }) { 37 | return {children}; 38 | }, 39 | h1({ children }) { 40 | return ( 41 | 42 | {children} 43 | 44 | ); 45 | }, 46 | h2({ children }) { 47 | return ( 48 | 49 | {children} 50 | 51 | ); 52 | }, 53 | h3({ children }) { 54 | return ( 55 | 56 | {children} 57 | 58 | ); 59 | }, 60 | h4({ children }) { 61 | return ( 62 | 63 | {children} 64 | 65 | ); 66 | }, 67 | h5({ children }) { 68 | return ( 69 | 70 | {children} 71 | 72 | ); 73 | }, 74 | h6({ children }) { 75 | return ( 76 | 77 | {children} 78 | 79 | ); 80 | }, 81 | }} 82 | > 83 | {children} 84 | 85 | ); 86 | } 87 | 88 | export default ChakraMarkdown; 89 | -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | 3 | const abbrLocations = [ 4 | { 5 | short: "kl", 6 | long: "kuala-lumpur", 7 | }, 8 | { 9 | short: "penang", 10 | long: "pulau-pinang", 11 | }, 12 | { 13 | short: "ns", 14 | long: "negeri-sembilan", 15 | }, 16 | ]; 17 | 18 | const frameworks = [ 19 | "react", 20 | "react-js", 21 | "react-native", 22 | "vue", 23 | "angular", 24 | "node-js", 25 | "laravel", 26 | "flutter", 27 | "django", 28 | "kotlin", 29 | "ruby-on-rails", 30 | "webflow", 31 | ]; 32 | 33 | const places = [ 34 | "johor", 35 | "kedah", 36 | "kelantan", 37 | "melaka", 38 | "negeri-sembilan", 39 | "pahang", 40 | "perak", 41 | "perlis", 42 | "pulau-pinang", 43 | "sarawak", 44 | "selangor", 45 | "terengganu", 46 | "kuala-lumpur", 47 | "labuan", 48 | "sabah", 49 | "putrajaya", 50 | "remote", 51 | ]; 52 | 53 | const nextConfig = { 54 | reactStrictMode: true, 55 | async redirects() { 56 | const abbrAutoComplete = ["all", ...frameworks] 57 | .map((tech) => 58 | abbrLocations.map(({ short, long }) => ({ 59 | source: `/${tech}/${short}`, 60 | destination: `/${tech}/${long}`, 61 | permanent: true, 62 | })) 63 | ) 64 | .flat(); 65 | 66 | const techRedirects = frameworks.map((tech) => ({ 67 | source: `/${tech}`, 68 | destination: `/${tech}/all`, 69 | permanent: true, 70 | })); 71 | 72 | const locationRedirects = places.map((location) => ({ 73 | source: `/${location}`, 74 | destination: `/all/${location}`, 75 | permanent: true, 76 | })); 77 | 78 | const abbrRedirects = abbrLocations.map(({ short, long }) => ({ 79 | source: `/${short}`, 80 | destination: `/all/${long}`, 81 | permanent: true, 82 | })); 83 | 84 | return [ 85 | locationRedirects, 86 | abbrRedirects, 87 | techRedirects, 88 | abbrAutoComplete, 89 | { 90 | source: "/hire", 91 | destination: "https://hire.kerja-it.com", 92 | permanent: false, 93 | }, 94 | { 95 | source: "/alerts", 96 | destination: 97 | "https://1f0b32ea.sibforms.com/serve/MUIEABbSjehs3QEh7ACtLDW0inFYOw6yH-stJTnS-GDMSl0bC3G4IGfo_unQnQrYko2qIyAK4PcWwZqjHhwYLiijP-gvwGEn9VVfjviAxghYR5skC5Vp2es7GxbhcHI5mMAmm0BLO9WA9cXeKcCWZMm6w_AJkG28XP3ixpyBiXgfEoNSh-2iKPJCOtsIPkfA2HWOlhcwseqR7Uek", 98 | permanent: false, 99 | }, 100 | { 101 | source: "/connect", 102 | destination: "/profile", 103 | permanent: false, 104 | }, 105 | ].flat(); 106 | }, 107 | }; 108 | 109 | module.exports = nextConfig; 110 | -------------------------------------------------------------------------------- /components/TechIcon.js: -------------------------------------------------------------------------------- 1 | import { Img } from "@chakra-ui/react"; 2 | import React from "react"; 3 | 4 | function TechIcon({ name, size = "21px" }) { 5 | switch (name) { 6 | case "react-native": 7 | case "react-js": 8 | case "react": 9 | return ( 10 | react logo 16 | ); 17 | case "angular": 18 | return ( 19 | angular logo 25 | ); 26 | case "node-js": 27 | return ( 28 | node.js logo 34 | ); 35 | case "flutter": 36 | return ( 37 | flutter logo 43 | ); 44 | case "laravel": 45 | return ( 46 | laravel logo 52 | ); 53 | case "vue": 54 | return ( 55 | vue logo 61 | ); 62 | case "django": 63 | return ( 64 | django logo 70 | ); 71 | case "kotlin": 72 | return ( 73 | kotlin logo 79 | ); 80 | case "ruby-on-rails": 81 | return ( 82 | kotlin logo 88 | ); 89 | case "webflow": 90 | return ( 91 | webflow logo 97 | ); 98 | case "tech": 99 | default: 100 | return null; 101 | } 102 | } 103 | 104 | export default TechIcon; 105 | -------------------------------------------------------------------------------- /pages/pricing.js: -------------------------------------------------------------------------------- 1 | import { Button, Flex, Heading, HStack, Text, VStack } from "@chakra-ui/react"; 2 | import Head from "next/head"; 3 | import React from "react"; 4 | 5 | function pricing() { 6 | return ( 7 |
8 | 9 | Pricing | Kerja IT 10 | 11 | 12 | 13 | Hire developers with kerja-it.com 14 | 15 | 27 | 28 | 29 | Talent Search 30 | 31 | Get connected with developers today 32 | 33 | 34 | 35 | RM99 36 | /month 37 | 38 | 39 | + 10% first year salary 40 | 41 | 48 | 49 | 50 | 51 | 64 | 65 | 66 | Job Advertising 67 | 68 | Active forever 69 | 70 | Broadcast to 50+ candidates via email directly 71 | 72 | 73 | 74 | 75 | RM59 76 | /post 77 | 78 | 81 | 82 | 83 | 84 | 85 |
86 | ); 87 | } 88 | 89 | export default pricing; 90 | -------------------------------------------------------------------------------- /pages/account.js: -------------------------------------------------------------------------------- 1 | import React, { useState } from "react"; 2 | import { SessionAuth } from "supertokens-auth-react/recipe/session"; 3 | import { 4 | Box, 5 | Button, 6 | Flex, 7 | Heading, 8 | Input, 9 | Stack, 10 | Text, 11 | } from "@chakra-ui/react"; 12 | 13 | import SectionContainer from "../components/SectionContainer"; 14 | import axios from "axios"; 15 | 16 | const initialState = { 17 | oldPassword: "", 18 | newPassword: "", 19 | repeatPassword: "", 20 | }; 21 | 22 | function Account() { 23 | const [password, setPassword] = useState(initialState); 24 | 25 | const { oldPassword, newPassword, repeatPassword } = password; 26 | 27 | const onChange = (e) => 28 | setPassword({ ...password, [e.target.name]: e.target.value }); 29 | 30 | const onSubmit = async (e) => { 31 | e.preventDefault(); 32 | if (newPassword !== repeatPassword) { 33 | alert("Password don't match"); 34 | } 35 | 36 | axios 37 | .post("/api/supertokens/change-password", password) 38 | .then(({ data }) => { 39 | alert(data?.msg); 40 | setPassword(initialState); 41 | }) 42 | .catch(({ response }) => alert(response?.data?.msg)); 43 | }; 44 | 45 | return ( 46 | 47 | 48 | 49 | Account Settings 50 | 51 | 52 | Change password 53 | 54 | 55 | Current Password 56 | 62 | 63 | 64 | New Password 65 | 71 | 72 | 73 | Repeat Password 74 | 80 | 81 | 82 | 83 | 86 | 87 | 88 | 89 | 99 | 100 | 101 | 102 | ); 103 | } 104 | 105 | export default Account; 106 | -------------------------------------------------------------------------------- /pages/[tech]/[location].js: -------------------------------------------------------------------------------- 1 | import Head from "next/head"; 2 | import React from "react"; 3 | import NextLink from "next/link"; 4 | import queryString from "query-string"; 5 | import { Box, Flex, Heading, HStack, Link } from "@chakra-ui/react"; 6 | 7 | import { getJobs } from "../../controllers/jobs"; 8 | import { capitalize } from "../../helpers/capitalize"; 9 | import { siteDescription } from "../../constants/SEO"; 10 | import { frameworks, places } from "../../constants/paths"; 11 | import FlagIcon from "../../components/FlagIcon"; 12 | import JobListing from "../../components/JobListing"; 13 | import TechIcon from "../../components/TechIcon"; 14 | 15 | export const getStaticProps = async (context) => { 16 | const { tech, location } = context.params; 17 | const { jobs } = await getJobs({ tech, location }); 18 | 19 | return { 20 | props: { 21 | jobs, 22 | location, 23 | tech: tech === "all" ? "tech" : tech, 24 | }, 25 | // revalidate every 1 hour 26 | revalidate: 60 * 60 * 1, 27 | }; 28 | }; 29 | 30 | export async function getStaticPaths() { 31 | const paths = [ 32 | ...frameworks.map((tech) => 33 | places.map((location) => ({ params: { tech, location } })) 34 | ), 35 | ...frameworks.map((tech) => ({ params: { tech, location: "all" } })), 36 | ...places.map((location) => ({ params: { tech: "all", location } })), 37 | ].flat(); 38 | 39 | return { 40 | paths, 41 | fallback: false, 42 | }; 43 | } 44 | 45 | function JobList({ jobs, tech, location }) { 46 | const techName = tech.replaceAll("-", " "); 47 | const locationName = 48 | location === "all" ? "malaysia 🇲🇾" : location.replaceAll("-", " "); 49 | 50 | const getPageTitle = () => { 51 | if (location === "remote") { 52 | return capitalize(`Remote ${techName} jobs 👨🏻‍💻🏝`); 53 | } 54 | return capitalize(`${techName} jobs in ${locationName}`); 55 | }; 56 | 57 | const getMoreHref = () => { 58 | const query = queryString.stringify( 59 | { 60 | tech: tech === "tech" ? null : tech, 61 | location: location === "all" ? null : location, 62 | }, 63 | { skipNull: true } 64 | ); 65 | return "/jobs?" + query; 66 | }; 67 | 68 | return ( 69 | 70 | 71 | {getPageTitle() + " | Kerja IT"} 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | {getPageTitle()} 81 | 82 | {location !== "remote" && } 83 | 84 | 85 | 86 | {jobs.map((job) => ( 87 | 88 | ))} 89 | 90 | 91 | 92 | 93 | Show more jobs 🚀 94 | 95 | 96 | 97 | 98 | ); 99 | } 100 | 101 | export default JobList; 102 | -------------------------------------------------------------------------------- /pages/api/alerts.js: -------------------------------------------------------------------------------- 1 | import Cryptr from "cryptr"; 2 | import queryString from "query-string"; 3 | import nodemailer from "nodemailer"; 4 | import axios from "axios"; 5 | import { format } from "date-fns"; 6 | 7 | import { getWeeklyJobs } from "../../controllers/jobs"; 8 | 9 | export default async function handler(req, res) { 10 | const { method, body } = req; 11 | 12 | if (method !== "POST") { 13 | return res.status(405).json({ status: "not allowed" }); 14 | } 15 | 16 | // verify valid request 17 | const { secret, list_id } = body; 18 | const isSecretValid = secret === process.env.ALERT_SECRET; 19 | if (!isSecretValid || !list_id) { 20 | return res.status(400).json({ status: "no body" }); 21 | } 22 | 23 | // get list of subscribers 24 | const url = `https://api.sendinblue.com/v3/contacts/lists/${list_id}/contacts`; 25 | 26 | // https://developers.sendinblue.com/reference/getcontactsfromlist 27 | const config = { headers: { "api-key": process.env.SENDINBLUE_API_KEY } }; 28 | const { data } = await axios.get(url, config); 29 | const subscribers = data?.contacts; 30 | 31 | // get new jobs of the week 32 | const { jobs } = await getWeeklyJobs(); 33 | 34 | // compose email body 35 | const jobList = jobs.map((j, i) => { 36 | const isAd = j?.source === "ad"; 37 | const url = "https://kerja-it.com/jobs/" + j?.slug; 38 | const n = i + 1; 39 | const title = j?.schema?.title ?? j?.title; 40 | 41 | let content = `${n}. ${title}`; 42 | 43 | const company = isAd 44 | ? j?.company?.name 45 | : j?.schema?.hiringOrganization?.name; 46 | 47 | const hasCompany = Boolean(company); 48 | if (hasCompany) { 49 | content += `
${company}`; 50 | } 51 | 52 | content += `
${url}`; 53 | 54 | const postedAt = format(new Date(j?.postedAt), "do MMM yyyy"); 55 | content += `
Posted on: ${postedAt}`; 56 | 57 | return content; 58 | }); 59 | 60 | const email_body = jobList.join("

"); 61 | 62 | // setup email & send 63 | const transporter = nodemailer.createTransport({ 64 | host: process.env.SMTP_HOST, 65 | port: process.env.SMTP_PORT, 66 | auth: { 67 | user: process.env.SMTP_EMAIL, 68 | pass: process.env.SMTP_PASSWORD, 69 | }, 70 | }); 71 | 72 | const cryptr = new Cryptr(process.env.UNSUBSCRIPTION_SECRET); 73 | 74 | const $email = subscribers.map((subscriber) => { 75 | const { email, attributes } = subscriber; 76 | const name = attributes.FIRSTNAME; 77 | 78 | // setup unsubscribe link 79 | const unsubscribe_link = queryString.stringifyUrl({ 80 | url: "https://kerja-it.com/emails/unsubscribe", 81 | query: { email, token: cryptr.encrypt(email) }, 82 | }); 83 | 84 | // setup email 85 | const email_header = `Hi ${name}, here are new jobs this week 🥳`; 86 | const email_footer = `No longer looking for a job? Unsubscribe`; 87 | const html = `${email_header}

${email_body}



${email_footer}`; 88 | 89 | const date = format(new Date(), "dd/MM"); 90 | const subject = `New jobs this week (${date})`; 91 | return transporter.sendMail({ 92 | from: '"🔔 Kerja IT Job Alerts" ', 93 | to: email, 94 | subject, 95 | html, 96 | }); 97 | }); 98 | 99 | await Promise.all($email); 100 | 101 | res.json({ status: "OK" }); 102 | } 103 | -------------------------------------------------------------------------------- /pages/api/fetch.js: -------------------------------------------------------------------------------- 1 | import fetch from "node-fetch"; 2 | import queryString from "query-string"; 3 | import { createManyJobs } from "../../controllers/jobs"; 4 | import { constructUrlQuery } from "../../helpers/constructUrlQuery"; 5 | import { extractJobDetails } from "../../helpers/extractJobDetails"; 6 | import { getKeywordsFromSnippet } from "../../helpers/getKeywordsFromSnippet"; 7 | import { notifyTelegram } from "../../helpers/notifyTelegram"; 8 | import { slugify } from "../../helpers/slugify"; 9 | 10 | const URL = "https://www.googleapis.com/customsearch/v1"; 11 | 12 | export default async function handler(req, res) { 13 | const { method, body } = req; 14 | 15 | if (method !== "POST") { 16 | return res.status(405).json({ status: "not allowed" }); 17 | } 18 | 19 | const { cx, key } = body; 20 | 21 | if (!cx || !key) { 22 | return res.status(400).json({ status: "no body" }); 23 | } 24 | 25 | const q = constructUrlQuery(); 26 | 27 | let results = []; 28 | let start = 1; 29 | 30 | while (true) { 31 | const requestUrl = queryString.stringifyUrl({ 32 | url: URL, 33 | query: { start, cx, key, q }, 34 | }); 35 | const result = await fetch(requestUrl).then((res) => res.json()); 36 | 37 | // add if has result 38 | if (result?.items?.length > 0) { 39 | results.push(...result.items); 40 | } 41 | 42 | // stop if no next page 43 | if (!result?.queries?.nextPage) { 44 | break; 45 | } 46 | 47 | start += 10; 48 | } 49 | 50 | if (results?.length === 0) { 51 | await notifyTelegram("vercel update – no jobs found"); 52 | return res.json({ status: "OK", message: "no jobs found" }); 53 | } 54 | 55 | const schemas = await Promise.all( 56 | results.map(({ link }) => extractJobDetails(link)) 57 | ); 58 | 59 | const withSchmeas = results.map(({ pagemap, ...rest }, i) => ({ 60 | ...rest, 61 | schema: schemas[i], 62 | })); 63 | 64 | const withKeywords = withSchmeas.map((job) => { 65 | const isRemote = 66 | job?.schema?.description?.includes("remote") || 67 | job?.schema?.responsibilities?.includes("remote"); 68 | const keywords = getKeywordsFromSnippet(job.htmlSnippet); 69 | 70 | return { 71 | ...job, 72 | keywords: isRemote ? [...keywords, "remote"] : keywords, 73 | }; 74 | }); 75 | 76 | const withSlug = withKeywords.map((job) => ({ ...job, slug: slugify(job) })); 77 | 78 | const inserted = await createManyJobs(withSlug); 79 | 80 | if (!inserted) { 81 | await notifyTelegram("vercel update – no jobs added because duplicates"); 82 | return res.json({ 83 | status: "OK", 84 | message: "no jobs added because duplicates", 85 | }); 86 | } 87 | 88 | // Send alert to telegram 89 | const count = withSlug.length; 90 | let telegram = `${count} new jobs!\n\n`; 91 | 92 | withSlug.forEach((job) => { 93 | const { schema, title, slug } = job; 94 | const applyUrl = "https://kerja-it.com/jobs/" + slug; 95 | if (schema) { 96 | const { title, hiringOrganization } = schema; 97 | const company = hiringOrganization?.name; 98 | const text = `${title} @ ${company}\n${applyUrl}\n\n\n`; 99 | telegram += text; 100 | } else { 101 | telegram += `${title}\n${applyUrl}\n\n\n`; 102 | } 103 | }); 104 | 105 | await notifyTelegram(telegram, true); 106 | await notifyTelegram(`vercel update – ${count} new jobs!`); 107 | 108 | res.json({ status: "OK", count }); 109 | } 110 | -------------------------------------------------------------------------------- /config/backendConfig.js: -------------------------------------------------------------------------------- 1 | import EmailPasswordNode from "supertokens-node/recipe/emailpassword"; 2 | import SessionNode from "supertokens-node/recipe/session"; 3 | import Dashboard from "supertokens-node/recipe/dashboard"; 4 | import UserMetadata from "supertokens-node/recipe/usermetadata"; 5 | 6 | import { appInfo } from "./appInfo"; 7 | import { createUser } from "../controllers/users"; 8 | import { addContactToList } from "../helpers/addContactToList"; 9 | import { notifyTelegram } from "../helpers/notifyTelegram"; 10 | 11 | export const backendConfig = () => { 12 | return { 13 | framework: "express", 14 | supertokens: { 15 | connectionURI: process.env.SUPERTOKENS_CONNECTION_URI, 16 | apiKey: process.env.SUPERTOKENS_API_KEY, 17 | }, 18 | appInfo: { 19 | ...appInfo, 20 | apiDomain: process.env.VERCEL_URL, 21 | websiteDomain: process.env.VERCEL_URL, 22 | }, 23 | recipeList: [ 24 | EmailPasswordNode.init({ 25 | signUpFeature: { formFields: [{ id: "name" }] }, 26 | override: { 27 | apis: (originalImplementation) => { 28 | return { 29 | ...originalImplementation, 30 | // add name to user metadata 31 | signUpPOST: async (input) => { 32 | if (originalImplementation.signUpPOST === undefined) { 33 | throw Error("Should never come here"); 34 | } 35 | const response = await originalImplementation.signUpPOST(input); 36 | if (response.status === "OK") { 37 | const formFields = input?.formFields; 38 | const name = formFields?.find((f) => f.id === "name"); 39 | const email = formFields?.find((f) => f.id === "email"); 40 | const userId = response.user.id; 41 | 42 | await UserMetadata.updateUserMetadata(userId, { 43 | first_name: name.value, 44 | }); 45 | 46 | await createUser({ 47 | name: name.value, 48 | email: email.value, 49 | superTokensId: userId, 50 | }); 51 | 52 | if (process.env.NODE_ENV === "production") { 53 | await addContactToList({ 54 | name: name.value, 55 | email: email.value, 56 | }); 57 | await notifyTelegram(`new user signed up – ${email.value}`); 58 | } 59 | } 60 | 61 | return response; 62 | }, 63 | }; 64 | }, 65 | }, 66 | }), 67 | SessionNode.init({ 68 | override: { 69 | // pass metadata to accessTokenPayload 70 | functions: (originalImplementation) => { 71 | return { 72 | ...originalImplementation, 73 | createNewSession: async (input) => { 74 | const { userId } = input; 75 | const { metadata } = await UserMetadata.getUserMetadata(userId); 76 | 77 | input.accessTokenPayload = { 78 | ...input.accessTokenPayload, 79 | ...metadata, 80 | }; 81 | 82 | return originalImplementation.createNewSession(input); 83 | }, 84 | }; 85 | }, 86 | }, 87 | }), 88 | UserMetadata.init(), 89 | Dashboard.init({ apiKey: process.env.SUPERTOKENS_DASHBOARD_KEY }), 90 | ], 91 | isInServerlessEnv: true, 92 | }; 93 | }; 94 | -------------------------------------------------------------------------------- /components/FlagIcon.js: -------------------------------------------------------------------------------- 1 | import { Img, Text } from "@chakra-ui/react"; 2 | import React from "react"; 3 | 4 | function FlagIcon({ name, size = "36px" }) { 5 | switch (name) { 6 | case "johor": 7 | return ( 8 | Johor flag 13 | ); 14 | case "kedah": 15 | return ( 16 | Kedah flag 21 | ); 22 | case "kelantan": 23 | return ( 24 | kelantan flag 29 | ); 30 | case "kuala-lumpur": 31 | return ( 32 | kuala lumpur flag 37 | ); 38 | case "labuan": 39 | return ( 40 | labuan flag 45 | ); 46 | case "melaka": 47 | return ( 48 | melaka flag 53 | ); 54 | case "negeri-sembilan": 55 | return ( 56 | negeri sembilan flag 61 | ); 62 | case "pahang": 63 | return ( 64 | pahang flag 69 | ); 70 | case "perak": 71 | return ( 72 | perak flag 77 | ); 78 | case "perlis": 79 | return ( 80 | perlis flag 85 | ); 86 | case "penang": 87 | case "pulau-pinang": 88 | return ( 89 | pulau pinang flag 94 | ); 95 | case "putrajaya": 96 | return ( 97 | putrajaya flag 102 | ); 103 | case "sabah": 104 | return ( 105 | sabah flag 110 | ); 111 | case "sarawak": 112 | return ( 113 | sarawak flag 118 | ); 119 | case "selangor": 120 | return ( 121 | selangor flag 126 | ); 127 | case "terengganu": 128 | return ( 129 | terengganu flag 134 | ); 135 | case "remote": 136 | return 🏝; 137 | case "all": 138 | return null; 139 | } 140 | return
FlagIcon
; 141 | } 142 | 143 | export default FlagIcon; 144 | -------------------------------------------------------------------------------- /components/JobListing.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import NextLink from "next/link"; 3 | import { format } from "date-fns"; 4 | import { 5 | Badge, 6 | Flex, 7 | HStack, 8 | LinkBox, 9 | LinkOverlay, 10 | Tag, 11 | Text, 12 | } from "@chakra-ui/react"; 13 | 14 | import { checkIfThisWeek } from "../helpers/checkIfThisWeek"; 15 | import { JOB_TYPE_TEXT } from "../types/jobs"; 16 | import PinIcon from "../icons/PinIcon"; 17 | import CalendarIcon from "../icons/CalendarIcon"; 18 | import BriefcaseIcon from "../icons/BriefcaseIcon"; 19 | import CashIcon from "../icons/CashIcon"; 20 | 21 | function JobListing({ job, featured = false }) { 22 | const title = job?.schema?.title || job?.title; 23 | const companyName = 24 | job?.company?.name || job?.schema?.hiringOrganization?.name; 25 | const datePosted = job?.postedAt; 26 | const dateCreated = job?.createdAt; 27 | const jobAdType = job?.location?.type; 28 | const jobAdLocation = job?.location?.city + ", " + job?.location?.state; 29 | 30 | const thisWeek = checkIfThisWeek(dateCreated); 31 | 32 | const getJobLocation = () => { 33 | switch (jobAdType) { 34 | case 1: 35 | return "Full Remote"; 36 | case 2: 37 | return jobAdLocation; 38 | case 3: 39 | return jobAdLocation + " (Hybrid)"; 40 | default: 41 | return ( 42 | job?.schema?.jobLocation?.address?.stressAddress || 43 | job?.schema?.jobLocation?.address?.addressLocality || 44 | job?.schema?.jobLocation?.address?.addressRegion 45 | ); 46 | } 47 | }; 48 | const jobLocation = getJobLocation(); 49 | 50 | const employmentType = job?.schema?.employmentType; 51 | const isEmploymentTypeArray = Array.isArray(employmentType); 52 | 53 | const employmentTypeValue = isEmploymentTypeArray 54 | ? employmentType.join(", ") 55 | : employmentType; 56 | 57 | const employmentTypeText = 58 | (employmentTypeValue ?? "")?.replaceAll("_", " ").toLowerCase() || 59 | JOB_TYPE_TEXT[job?.type] || 60 | "Unspecified"; 61 | 62 | const salary = job?.salary; 63 | 64 | return ( 65 | 75 | 76 | 77 | 78 | {title} 79 | 80 | 81 | 82 | {thisWeek && New} 83 | 84 | 85 | {companyName && {companyName}} 86 | 87 | 88 | 89 | {datePosted 90 | ? "Posted on " + format(new Date(datePosted), "do MMM yyyy") 91 | : "Unspecified"} 92 | 93 | 94 | 95 | 96 | {jobLocation ?? "Unspecified"} 97 | 98 | 99 | 100 | 101 | {employmentTypeText} 102 | 103 | 104 | {Boolean(salary) && ( 105 | 106 | 107 | 108 | {new Intl.NumberFormat("en-UK", { 109 | style: "currency", 110 | currency: "MYR", 111 | }).format(salary)} 112 | 113 | 114 | )} 115 | 116 | 117 | {job?.keywords.map((keyword) => ( 118 | 119 | {keyword} 120 | 121 | ))} 122 | 123 | 124 | 125 | {dateCreated && ( 126 | 127 | {"Added on " + format(new Date(dateCreated), "do MMM yyyy")} 128 | 129 | )} 130 | 131 | 132 | ); 133 | } 134 | 135 | export default JobListing; 136 | -------------------------------------------------------------------------------- /pages/index.js: -------------------------------------------------------------------------------- 1 | import Head from "next/head"; 2 | import NextLink from "next/link"; 3 | import { 4 | Box, 5 | Button, 6 | Flex, 7 | Heading, 8 | SimpleGrid, 9 | Stack, 10 | Text, 11 | } from "@chakra-ui/react"; 12 | 13 | import { siteDescription } from "../constants/SEO"; 14 | import { 15 | getFeaturedJobs, 16 | getLatestJobs, 17 | getTotalJobsCount, 18 | } from "../controllers/jobs"; 19 | import JobListing from "../components/JobListing"; 20 | 21 | export const getStaticProps = async () => { 22 | const { jobs: latest } = await getLatestJobs(30); 23 | const { featured } = await getFeaturedJobs(); 24 | const { count } = await getTotalJobsCount(); 25 | 26 | return { 27 | props: { 28 | latest, 29 | featured, 30 | count, 31 | }, 32 | // revalidate every 10 minutes 33 | revalidate: 60 * 10, 34 | }; 35 | }; 36 | 37 | export default function Home({ latest, featured, count }) { 38 | const title = "Find Tech Jobs In Malaysia 🇲🇾 | Kerja IT"; 39 | const hasFeatured = featured?.length > 0; 40 | return ( 41 | 42 | 43 | {title} 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | {/* Header */} 56 | 63 | Find Tech Jobs In Malaysia 🇲🇾 64 | 65 | Let employers find you. Or apply to companies directly. 66 | 67 | 68 | 69 | 70 | 71 | 81 | 84 | 85 | 86 | 87 | {/* Latest Jobs */} 88 | {hasFeatured && ( 89 | <> 90 | 91 | 92 | 🦄 Featured Jobs 93 | 94 | 98 | {featured?.map((job) => ( 99 | 100 | ))} 101 | 102 | 103 | 109 | 110 | Want your job listed here? 111 | 112 | 115 | 116 | 117 | )} 118 | 119 | 120 | 121 | ⏳ Latest Jobs → 122 | 123 | 124 | 125 | {latest.map(({ featured, ...job }) => ( 126 | 127 | ))} 128 | 129 | 130 | 133 | 134 | 135 | 136 | 137 | ); 138 | } 139 | -------------------------------------------------------------------------------- /pages/stats.js: -------------------------------------------------------------------------------- 1 | import { Box, Flex, Heading, Input } from "@chakra-ui/react"; 2 | import { 3 | CategoryScale, 4 | Chart as ChartJS, 5 | LineElement, 6 | LinearScale, 7 | PointElement, 8 | Title, 9 | Tooltip, 10 | } from "chart.js"; 11 | import { format, isAfter, isBefore, parseISO } from "date-fns"; 12 | import Head from "next/head"; 13 | import React, { useEffect, useState } from "react"; 14 | import { Line } from "react-chartjs-2"; 15 | import { siteDescription } from "../constants/SEO"; 16 | import { getJobCount } from "../controllers/jobs"; 17 | 18 | const title = "Statistics | Kerja IT"; 19 | 20 | ChartJS.register( 21 | CategoryScale, 22 | LinearScale, 23 | PointElement, 24 | LineElement, 25 | Title, 26 | Tooltip 27 | ); 28 | 29 | const getDatesArrayFromPastToToday = (initial) => { 30 | const today = new Date(); 31 | const pastDate = new Date(initial); // Replace this with your desired past date 32 | 33 | const datesArray = []; 34 | while (pastDate <= today) { 35 | const formattedDate = new Date(pastDate).toISOString(); 36 | datesArray.push(formattedDate); 37 | 38 | pastDate.setDate(pastDate.getDate() + 1); 39 | } 40 | 41 | return datesArray; 42 | }; 43 | 44 | export const getStaticProps = async () => { 45 | const { jobCount } = await getJobCount(); 46 | 47 | return { 48 | props: { 49 | jobCount, 50 | }, 51 | // revalidate every 10 minutes 52 | revalidate: 60 * 10, 53 | }; 54 | }; 55 | 56 | function Stats({ jobCount }) { 57 | const oneMonthAgo = new Date(); 58 | oneMonthAgo.setMonth(oneMonthAgo.getMonth() - 1); 59 | const initialStartDate = format(oneMonthAgo, "yyyy-MM-dd"); 60 | 61 | const labels = getDatesArrayFromPastToToday(initialStartDate); 62 | const [range, setRange] = useState({ 63 | startDate: initialStartDate, 64 | endDate: format(new Date(), "yyyy-MM-dd"), 65 | }); 66 | 67 | const [dataset, setDataset] = useState( 68 | labels.map((l) => format(parseISO(l), "dd-MM-yyyy")) 69 | ); 70 | 71 | useEffect(() => { 72 | const newRange = labels 73 | .filter((l) => { 74 | const afterStart = isAfter(parseISO(l), parseISO(range.startDate)); 75 | const beforeEnd = isBefore(parseISO(l), parseISO(range.endDate)); 76 | return afterStart && beforeEnd; 77 | }) 78 | .map((l) => format(parseISO(l), "dd-MM-yyyy")); 79 | 80 | setDataset(newRange); 81 | // eslint-disable-next-line react-hooks/exhaustive-deps 82 | }, [range]); 83 | 84 | const data = { 85 | labels: dataset, 86 | datasets: [ 87 | { 88 | label: "New Jobs", 89 | data: dataset.map((l) => { 90 | const dayCount = jobCount.find( 91 | (j) => format(parseISO(j.createdAt), "dd-MM-yyyy") === l 92 | ); 93 | 94 | if (!dayCount) { 95 | return 0; 96 | } 97 | return dayCount.count; 98 | }), 99 | borderColor: "#6b7280", 100 | backgroundColor: "#6b7280", 101 | }, 102 | ], 103 | }; 104 | 105 | return ( 106 |
107 | 108 | {title} 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 126 | Kerja IT Statistics 📊 127 | 128 | 129 | 137 | setRange({ ...range, startDate: e.target.value }) 138 | } 139 | /> 140 | setRange({ ...range, endDate: e.target.value })} 148 | /> 149 | 150 | 164 | 165 | 166 |
167 | ); 168 | } 169 | 170 | export default Stats; 171 | -------------------------------------------------------------------------------- /pages/talents/index.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import Head from "next/head"; 3 | import useSWR from "swr"; 4 | import NextLink from "next/link"; 5 | import { Spinner } from "@chakra-ui/react"; 6 | import { Tag } from "@chakra-ui/tag"; 7 | import { Button } from "@chakra-ui/button"; 8 | import { 9 | Box, 10 | Flex, 11 | Heading, 12 | Link, 13 | LinkBox, 14 | LinkOverlay, 15 | Stack, 16 | Text, 17 | } from "@chakra-ui/layout"; 18 | import { formatDistanceToNow } from "date-fns"; 19 | import { fetcher } from "../../helpers/fetcher"; 20 | 21 | function Talents() { 22 | const { data, isLoading } = useSWR("/api/devs", fetcher); 23 | const title = "Talents | Kerja IT"; 24 | const siteDescription = "Hire developers from Malaysia with Kerja IT"; 25 | 26 | return ( 27 |
28 | 29 | {title} 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 47 | Hire Developers in Malaysia 🇲🇾 48 | 49 | Why wait for candidates to apply? Connect with them today! 50 | 51 | 52 | 53 | 54 | 65 | 73 | 74 | 75 | 76 | {isLoading ? ( 77 | 78 | 79 | 80 | ) : ( 81 | data?.devs?.map((d) => { 82 | const activelyLooking = d?.status === "active"; 83 | return ( 84 | 95 | 96 | {activelyLooking && ( 97 | 98 | 99 | Actively looking 100 | 101 | 102 | )} 103 | 108 | 114 | {d?.headline ?? `Developer ${d._id}`} 115 | 116 | 117 | 118 | 125 | {d?.bio} 126 | 127 | 128 | Last updated{" "} 129 | {formatDistanceToNow(new Date(d?.updatedAt), { 130 | addSuffix: true, 131 | })} 132 | 133 | 134 | ); 135 | }) 136 | )} 137 | 138 | 139 | 140 | You're a developer? Add your profile ✍️ 141 | 142 | 143 | 144 |
145 | ); 146 | } 147 | 148 | export default Talents; 149 | -------------------------------------------------------------------------------- /components/Filters.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { 3 | Checkbox, 4 | Flex, 5 | Heading, 6 | HStack, 7 | Select, 8 | Text, 9 | } from "@chakra-ui/react"; 10 | 11 | import { frameworks, places } from "../constants/paths"; 12 | import FlagIcon from "./FlagIcon"; 13 | import TechIcon from "./TechIcon"; 14 | import { useRouter } from "next/router"; 15 | 16 | function Filters({ 17 | sortBy, 18 | setSortBy, 19 | techValue, 20 | locationValue, 21 | setPage, 22 | onChangeTech, 23 | onChangeLocation, 24 | techGetCheckboxProps, 25 | locationGetCheckboxProps, 26 | jobTypeValue, 27 | onChangeJobType, 28 | jobTypeGetCheckboxProps, 29 | }) { 30 | const router = useRouter(); 31 | const onChangeJobTypeCustom = (e) => { 32 | const jobType = e.target.value; 33 | const isSelected = jobTypeValue.some((t) => t === jobType); 34 | 35 | let newJobTypeValue = []; 36 | if (isSelected) { 37 | newJobTypeValue = jobTypeValue.filter((t) => t !== jobType); 38 | } else { 39 | newJobTypeValue = [...jobTypeValue, jobType]; 40 | } 41 | setPage(1); 42 | onChangeJobType(e); 43 | router.push( 44 | { 45 | query: { 46 | tech: techValue, 47 | location: locationValue, 48 | sortBy: sortBy, 49 | jobType: newJobTypeValue, 50 | }, 51 | }, 52 | undefined, 53 | { 54 | shallow: true, 55 | } 56 | ); 57 | }; 58 | 59 | const onChangeTechCustom = (e) => { 60 | const tech = e.target.value; 61 | const isSelected = techValue.some((t) => t === tech); 62 | 63 | let newTechValue = []; 64 | if (isSelected) { 65 | newTechValue = techValue.filter((t) => t !== tech); 66 | } else { 67 | newTechValue = [...techValue, tech]; 68 | } 69 | setPage(1); 70 | onChangeTech(e); 71 | router.push( 72 | { query: { tech: newTechValue, location: locationValue, sortBy } }, 73 | undefined, 74 | { 75 | shallow: true, 76 | } 77 | ); 78 | }; 79 | 80 | const onChangeLocationCustom = (e) => { 81 | const location = e.target.value; 82 | const isSelected = locationValue.some((t) => t === location); 83 | 84 | let newLocationValue = []; 85 | if (isSelected) { 86 | newLocationValue = locationValue.filter((t) => t !== location); 87 | } else { 88 | newLocationValue = [...locationValue, location]; 89 | } 90 | 91 | setPage(1); 92 | onChangeLocation(e); 93 | router.push( 94 | { 95 | query: { location: newLocationValue, tech: techValue, sortBy }, 96 | }, 97 | undefined, 98 | { shallow: true } 99 | ); 100 | }; 101 | 102 | const onChangeSort = (e) => { 103 | setSortBy(e.target.value); 104 | router.push( 105 | { 106 | query: { 107 | tech: techValue, 108 | location: locationValue, 109 | sortBy: e.target.value, 110 | }, 111 | }, 112 | undefined, 113 | { shallow: true } 114 | ); 115 | }; 116 | 117 | return ( 118 | <> 119 | 120 | Sort Jobs 🌊 121 | 122 | 126 | 127 | 128 | By Job Type 👷🏻‍♂️ 129 | 130 | 131 | 132 | {["full time", "part time", "contract", "internship"].map((f, i) => ( 133 | 139 | 140 | 141 | {f.replaceAll("-", " ")} 142 | 143 | 144 | 145 | ))} 146 | 147 | 148 | 149 | By Tech ⚡️ 150 | 151 | 152 | 153 | {frameworks.map((f, i) => ( 154 | 160 | 161 | 162 | 163 | {f.replaceAll("-", " ")} 164 | 165 | 166 | 167 | ))} 168 | 169 | 170 | 171 | By Location 📍 172 | 173 | 174 | 175 | {places.map((f, i) => ( 176 | 182 | 183 | 184 | 185 | {f.replaceAll("-", " ")} 186 | 187 | 188 | 189 | ))} 190 | 191 | 192 | ); 193 | } 194 | 195 | export default Filters; 196 | -------------------------------------------------------------------------------- /pages/talents/[id].js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import Head from "next/head"; 3 | import NextLink from "next/link"; 4 | import { Tag } from "@chakra-ui/tag"; 5 | import { Button } from "@chakra-ui/button"; 6 | import { Box, Flex, Heading, Link, Stack, Text } from "@chakra-ui/layout"; 7 | import { 8 | Breadcrumb, 9 | BreadcrumbItem, 10 | BreadcrumbLink, 11 | } from "@chakra-ui/breadcrumb"; 12 | import useSWR from "swr"; 13 | import { useRouter } from "next/router"; 14 | import { fetcher } from "../../helpers/fetcher"; 15 | import { format, formatDistanceToNow } from "date-fns"; 16 | import { useSessionContext } from "supertokens-auth-react/recipe/session"; 17 | 18 | function HireMeButton({ isOwnPage }) { 19 | if (isOwnPage) { 20 | return ( 21 | 24 | ); 25 | } 26 | 27 | return ( 28 | 40 | ); 41 | } 42 | 43 | function Profile() { 44 | const { userId } = useSessionContext(); 45 | const router = useRouter(); 46 | const { data } = useSWR("/api/devs/" + router.query.id, fetcher); 47 | 48 | const hasProfile = Object.keys(data?.dev ?? {}).length > 0; 49 | const title = data?.dev 50 | ? `${data?.dev?.headline} | Kerja IT` 51 | : "Developer Not Found | Kerja IT"; 52 | const description = data?.dev?.bio; 53 | const activelyLooking = data?.dev?.status === "active"; 54 | const isOwnPage = userId === data?.dev?.superTokensId; 55 | 56 | const formatArray = (array) => 57 | array.map((a) => a.replaceAll("_", " ")).join(", "); 58 | 59 | return ( 60 |
61 | 62 | {title} 63 | 64 | 65 | 66 | 67 | 68 | {hasProfile && ( 69 | 70 | 71 | Talents 72 | 73 | 74 | 75 | 76 | {data?.dev?.headline} 77 | 78 | 79 | 80 | )} 81 | {!hasProfile && ( 82 | 91 | 😵‍💫 Opps... 92 | 93 | Can't find the candidate you're looking for. 94 | 95 | 96 | 99 | 100 | 101 | )} 102 | {hasProfile && ( 103 | 114 | {activelyLooking && ( 115 | 116 | 117 | Actively looking 118 | 119 | 120 | )} 121 | 122 | {data?.dev?.headline} 123 | 124 | 125 | 126 | 127 | 128 | Last updated: 129 | 130 | 131 | {formatDistanceToNow(new Date(data?.dev?.updatedAt), { 132 | addSuffix: true, 133 | })} 134 | 135 | 136 | 137 | 138 | Available date: 139 | 140 | 141 | {data?.dev?.availableDate && 142 | format(new Date(data?.dev?.availableDate), "do MMM yyyy")} 143 | 144 | 145 | 146 | 147 | Available in: 148 | 149 | 150 | {formatArray(data?.dev?.locations)} 151 | 152 | 153 | 154 | 155 | Looking for: 156 | 157 | 158 | {formatArray(data?.dev?.jobTypes)} 159 | 160 | 161 | 162 | 163 | Preferred position: 164 | 165 | 166 | {formatArray(data?.dev?.positions)} 167 | 168 | 169 | 170 | 171 | Interested in role as: 172 | 173 | 174 | {formatArray(data?.dev?.jobLevels)} 175 | 176 | 177 | 178 | 179 | Work arrangements: 180 | 181 | 182 | {formatArray(data?.dev?.arrangements)} 183 | 184 | 185 | 186 | 187 | Profile: 188 | 189 | 195 | {data?.dev?.bio} 196 | 197 | 198 | 199 | 200 | )} 201 | 202 | 203 | You're a developer? Add your profile ✍️ 204 | 205 | 206 |
207 | ); 208 | } 209 | 210 | export default Profile; 211 | -------------------------------------------------------------------------------- /pages/jobs/index.js: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from "react"; 2 | import Head from "next/head"; 3 | import useSWR from "swr"; 4 | import queryString from "query-string"; 5 | import { useRouter } from "next/router"; 6 | import { 7 | Box, 8 | Button, 9 | Flex, 10 | Heading, 11 | HStack, 12 | Link, 13 | Spinner, 14 | Stack, 15 | Tag, 16 | Text, 17 | useCheckboxGroup, 18 | } from "@chakra-ui/react"; 19 | 20 | import { siteDescription } from "../../constants/SEO"; 21 | import { standardizeQuery } from "../../helpers/query"; 22 | import JobListing from "../../components/JobListing"; 23 | import FilterCard from "../../components/FilterCard"; 24 | import FilterDesktop from "../../components/FilterDesktop"; 25 | import { fetcher } from "../../helpers/fetcher"; 26 | 27 | function Search() { 28 | const router = useRouter(); 29 | const [page, setPage] = useState(1); 30 | const [sortBy, setSortBy] = useState("posted"); 31 | 32 | const jobTypeFilter = useCheckboxGroup(); 33 | const techFilter = useCheckboxGroup(); 34 | const locationFilter = useCheckboxGroup(); 35 | 36 | const query = queryString.stringify({ 37 | page, 38 | sortBy: router.query.sortBy ? router.query.sortBy : sortBy, 39 | jobType: router.query.jobType ? router.query.jobType : jobTypeFilter.value, 40 | tech: router.query.tech ? router.query.tech : techFilter.value, 41 | location: router.query.location 42 | ? router.query.location 43 | : locationFilter.value, 44 | }); 45 | 46 | const { data, isLoading } = useSWR("/api/jobs?" + query, fetcher); 47 | 48 | const [hasNext, setHasNext] = useState(true); 49 | const [hasPrevious, setHasPrevious] = useState(true); 50 | const [jobs, setJobs] = useState([]); 51 | const featured = data?.featured; 52 | 53 | useEffect(() => { 54 | if (router?.query?.tech) { 55 | techFilter.setValue(standardizeQuery(router.query.tech)); 56 | } else { 57 | techFilter.setValue([]); 58 | } 59 | if (router?.query?.location) { 60 | locationFilter.setValue(standardizeQuery(router.query.location)); 61 | } else { 62 | locationFilter.setValue([]); 63 | } 64 | if (router?.query?.jobType) { 65 | jobTypeFilter.setValue(standardizeQuery(router.query.jobType)); 66 | } else { 67 | jobTypeFilter.setValue([]); 68 | } 69 | // eslint-disable-next-line react-hooks/exhaustive-deps 70 | }, [router.query]); 71 | 72 | useEffect(() => { 73 | const hasNext = data?.jobs?.length === 10; 74 | const hasPrevious = page > 1; 75 | setJobs(data?.jobs); 76 | setHasNext(hasNext); 77 | setHasPrevious(hasPrevious); 78 | }, [data?.jobs, page]); 79 | 80 | const onLoadNext = () => setPage(page + 1); 81 | const onLoadPrevious = () => setPage(page - 1); 82 | 83 | const tags = [techFilter.value, locationFilter.value].flat(); 84 | const hasTags = tags?.length > 0; 85 | const hasFeatured = featured?.length > 0; 86 | 87 | return ( 88 | 89 | 90 | Find your next tech jobs in Malaysia 🇲🇾 | Kerja IT 91 | 92 | 93 | 94 | 101 | {/* Job Listing */} 102 | 107 | 108 | 👨🏻‍💻 Explore Jobs 109 | 110 | 111 | 112 | Showing results for: 113 | 114 | {!hasTags && ( 115 | 116 | All 117 | 118 | )} 119 | {tags.map((v, i) => ( 120 | 121 | {v.replaceAll("-", " ")} 122 | 123 | ))} 124 | 125 |
126 | {isLoading ? ( 127 | 128 | 129 | 130 | ) : ( 131 | 132 | {hasFeatured && ( 133 | <> 134 | {featured?.map((job) => ( 135 | 136 | ))} 137 | 143 | 144 | Want your job listed here? 145 | 146 | 149 | 150 | 151 | )} 152 | {jobs?.map(({ featured, ...job }) => ( 153 | 154 | ))} 155 | 156 | 164 | 172 | 173 | 174 | )} 175 | 176 | 177 | Applied but no response? Drop your resume here 📥 178 | 179 | 180 |
181 |
182 | 196 |
197 | 211 |
212 | ); 213 | } 214 | 215 | export default Search; 216 | -------------------------------------------------------------------------------- /components/GlobalHeader.js: -------------------------------------------------------------------------------- 1 | import React, { useContext } from "react"; 2 | import Image from "next/image"; 3 | import NextLink from "next/link"; 4 | import { useRouter } from "next/router"; 5 | import { signOut } from "supertokens-auth-react/recipe/emailpassword"; 6 | import { useSessionContext } from "supertokens-auth-react/recipe/session"; 7 | 8 | import { 9 | HStack, 10 | Flex, 11 | IconButton, 12 | useDisclosure, 13 | CloseButton, 14 | Button, 15 | Spacer, 16 | Menu, 17 | MenuButton, 18 | MenuList, 19 | MenuItem, 20 | Box, 21 | Stack, 22 | } from "@chakra-ui/react"; 23 | import { AiOutlineMenu } from "react-icons/ai"; 24 | import { UserContext } from "../context/user"; 25 | 26 | const GlobalHeader = () => { 27 | const router = useRouter(); 28 | const isHome = router.pathname === "/"; 29 | const isTalents = router.pathname.includes("/talents"); 30 | 31 | const ref = React.useRef(null); 32 | const mobileNav = useDisclosure(); 33 | 34 | const sessionContext = useSessionContext(); 35 | const { doesSessionExist } = sessionContext; 36 | const { name } = useContext(UserContext); 37 | 38 | const onLogout = async () => { 39 | router.push("/"); 40 | await signOut(); 41 | mobileNav.onClose(); 42 | }; 43 | 44 | const MobileNavContent = ( 45 | 61 | 68 | 77 | 86 | {!isTalents && ( 87 | 96 | )} 97 | {!isTalents && ( 98 | 107 | )} 108 | {!doesSessionExist && ( 109 | <> 110 | 121 | 122 | 131 | 132 | 144 | 145 | )} 146 | {doesSessionExist && ( 147 | <> 148 | {!isTalents && ( 149 | 158 | )} 159 | 168 | 177 | 180 | 181 | )} 182 | 183 | ); 184 | 185 | return ( 186 | 194 | 195 | 202 | 203 | 204 | 205 | Kerja IT logo 211 | 212 | 213 | 214 | 215 | 216 | 219 | {!isTalents && ( 220 | 223 | )} 224 | 227 | {!doesSessionExist && ( 228 | <> 229 | {!isTalents && ( 230 | 233 | )} 234 | {!isTalents && ( 235 | 238 | )} 239 | {isHome ? ( 240 | 249 | ) : !isTalents ? ( 250 | 260 | ) : null} 261 | 262 | )} 263 | {doesSessionExist && ( 264 | <> 265 | 266 | 273 | 👋 {name} 274 | 275 | 276 | 277 | 📝 My Profile 278 | 279 | 280 | ⚙️ Account 281 | 282 | 🚶🏻‍♂️ Sign Out 283 | 284 | 285 | 286 | )} 287 | 288 | {(!isTalents || doesSessionExist) && ( 289 | 290 | } 298 | onClick={mobileNav.onOpen} 299 | /> 300 | 301 | )} 302 | 303 | {MobileNavContent} 304 | 305 | 306 | ); 307 | }; 308 | export default GlobalHeader; 309 | -------------------------------------------------------------------------------- /controllers/jobs.js: -------------------------------------------------------------------------------- 1 | import { sub } from "date-fns"; 2 | import { ObjectId } from "mongodb"; 3 | import { places } from "../constants/paths"; 4 | import { connectToDatabase } from "../libs/mongo"; 5 | 6 | export const createManyJobs = async (data) => { 7 | const { db } = await connectToDatabase(); 8 | const createdAt = new Date().toISOString(); 9 | 10 | const filterPromise = data.map((d) => 11 | db.collection("jobs").find({ link: d.link }).toArray() 12 | ); 13 | 14 | const results = await Promise.all(filterPromise); 15 | const filterResults = results.flat(); 16 | 17 | const filteredData = data.filter((d) => { 18 | const currentLink = d.link; 19 | const hasDuplicates = filterResults.some((r) => r.link === currentLink); 20 | 21 | return !hasDuplicates; 22 | }); 23 | 24 | if (filteredData.length === 0) { 25 | return; 26 | } 27 | 28 | const formattedData = filteredData.map((d) => { 29 | const postedAt = Boolean(d?.schema?.datePosted) 30 | ? d?.schema?.datePosted 31 | : createdAt; 32 | 33 | return { 34 | ...d, 35 | createdAt, 36 | postedAt, 37 | }; 38 | }); 39 | 40 | const jobs = await db.collection("jobs").insertMany(formattedData); 41 | return jobs; 42 | }; 43 | 44 | export const getWeeklyJobs = async () => { 45 | const { db } = await connectToDatabase(); 46 | 47 | const pipeline = [ 48 | { 49 | $addFields: { 50 | date: { 51 | $dateFromString: { 52 | dateString: "$postedAt", 53 | }, 54 | }, 55 | }, 56 | }, 57 | { 58 | $match: { 59 | date: { 60 | $gte: new Date(sub(new Date(), { days: 7 })), 61 | }, 62 | }, 63 | }, 64 | { 65 | $match: { 66 | keywords: { 67 | $in: places.map((p) => p.replaceAll("-", " ")), 68 | }, 69 | }, 70 | }, 71 | { 72 | $project: { 73 | source: 1, 74 | company: 1, 75 | title: 1, 76 | slug: 1, 77 | postedAt: 1, 78 | "schema.title": 1, 79 | "schema.hiringOrganization.name": 1, 80 | }, 81 | }, 82 | ]; 83 | 84 | const cursor = await db 85 | .collection("jobs") 86 | .aggregate(pipeline) 87 | .sort({ postedAt: -1 }) 88 | .toArray(); 89 | 90 | const jobs = JSON.parse(JSON.stringify(cursor)); 91 | 92 | return { jobs }; 93 | }; 94 | 95 | export const getLatestJobs = async (limit) => { 96 | const { db } = await connectToDatabase(); 97 | 98 | const pipeline = [ 99 | { 100 | $match: { 101 | featuredUntil: null, 102 | }, 103 | }, 104 | { 105 | $match: { 106 | keywords: { 107 | $in: places.map((p) => p.replaceAll("-", " ")), 108 | }, 109 | }, 110 | }, 111 | ]; 112 | 113 | const cursor = await db 114 | .collection("jobs") 115 | .aggregate(pipeline) 116 | .sort({ postedAt: -1 }) 117 | .limit(limit) 118 | .toArray(); 119 | 120 | const jobs = JSON.parse(JSON.stringify(cursor)); 121 | 122 | return { jobs }; 123 | }; 124 | 125 | export const getRemoteJobs = async (limit) => { 126 | const { db } = await connectToDatabase(); 127 | 128 | const pipeline = [ 129 | { 130 | $match: { 131 | keywords: { 132 | $in: ["remote"], 133 | }, 134 | }, 135 | }, 136 | ]; 137 | 138 | const cursor = await db 139 | .collection("jobs") 140 | .aggregate(pipeline) 141 | .sort({ postedAt: -1 }) 142 | .limit(limit) 143 | .toArray(); 144 | 145 | const jobs = JSON.parse(JSON.stringify(cursor)); 146 | 147 | return { jobs }; 148 | }; 149 | 150 | export const getJobs = async ({ tech, location }) => { 151 | const { db } = await connectToDatabase(); 152 | 153 | const pipeline = constructPipeline({ tech, location }); 154 | 155 | const cursor = await db 156 | .collection("jobs") 157 | .aggregate(pipeline) 158 | .sort({ postedAt: -1 }) 159 | .limit(10) 160 | .toArray(); 161 | 162 | const jobs = JSON.parse(JSON.stringify(cursor)); 163 | return { jobs }; 164 | }; 165 | 166 | export const getJobsJSON = async ({ 167 | tech, 168 | location, 169 | page = 1, 170 | limit = 10, 171 | sortBy = "posted", 172 | jobType, 173 | }) => { 174 | const { db } = await connectToDatabase(); 175 | 176 | const serializeTech = tech?.map((t) => serialize(t)).flat(); 177 | const serializeLocation = location?.map((l) => serialize(l)).flat(); 178 | const skip = page - 1 < 0 ? 0 : page - 1; 179 | 180 | let pipeline = []; 181 | 182 | if (serializeTech?.length > 0) { 183 | pipeline.push({ 184 | $match: { 185 | keywords: { 186 | $in: serializeTech, 187 | }, 188 | }, 189 | }); 190 | } 191 | 192 | if (serializeLocation?.length > 0) { 193 | pipeline.push({ 194 | $match: { 195 | keywords: { 196 | $in: serializeLocation, 197 | }, 198 | }, 199 | }); 200 | } else { 201 | pipeline.push({ 202 | $match: { 203 | keywords: { 204 | $in: places.map((p) => p.replaceAll("-", " ")), 205 | }, 206 | }, 207 | }); 208 | } 209 | 210 | if (jobType?.length > 0) { 211 | const jobTypePipeline = jobType?.map((j) => { 212 | const fullTime = [ 213 | "full-time", 214 | "full time", 215 | "full_time", 216 | "FULL-TIME", 217 | "FULL TIME", 218 | "FULL_TIME", 219 | ]; 220 | const partTime = [ 221 | "part-time", 222 | "part time", 223 | "part_time", 224 | "PART-TIME", 225 | "PART TIME", 226 | "PART_TIME", 227 | ]; 228 | const contract = ["contract", "CONTRACT"]; 229 | const internship = ["internship", "INTERNSHIP", "intern", "INTERN"]; 230 | 231 | switch (j) { 232 | case "full time": 233 | return fullTime; 234 | case "part time": 235 | return partTime; 236 | case "contract": 237 | return contract; 238 | case "internship": 239 | return internship; 240 | default: 241 | return; 242 | } 243 | }); 244 | 245 | pipeline.push({ 246 | $match: { 247 | "schema.employmentType": { 248 | $in: jobTypePipeline.flat(), 249 | }, 250 | }, 251 | }); 252 | } 253 | 254 | if (sortBy === "posted") { 255 | pipeline.push({ $sort: { postedAt: -1 } }); 256 | } else { 257 | pipeline.push({ $sort: { createdAt: -1 } }); 258 | } 259 | 260 | pipeline = [ 261 | ...pipeline, 262 | { 263 | $match: { 264 | featuredUntil: null, 265 | }, 266 | }, 267 | { $skip: skip * limit }, 268 | { $limit: limit }, 269 | ]; 270 | 271 | const cursor = await db.collection("jobs").aggregate(pipeline).toArray(); 272 | const jobs = JSON.parse(JSON.stringify(cursor)); 273 | 274 | return { jobs }; 275 | }; 276 | 277 | export const getFeaturedJobs = async () => { 278 | const { db } = await connectToDatabase(); 279 | 280 | const pipeline = [ 281 | { 282 | $match: { 283 | featuredUntil: { 284 | $gte: new Date(), 285 | }, 286 | }, 287 | }, 288 | { $sort: { postedAt: -1 } }, 289 | ]; 290 | 291 | const cursor = await db.collection("jobs").aggregate(pipeline).toArray(); 292 | const featured = JSON.parse(JSON.stringify(cursor)); 293 | 294 | return { featured }; 295 | }; 296 | 297 | const constructPipeline = ({ tech, location }) => { 298 | if (tech === "all") { 299 | return [ 300 | { 301 | $match: { 302 | keywords: { 303 | $in: serialize(location), 304 | }, 305 | }, 306 | }, 307 | ]; 308 | } 309 | 310 | // specific all 311 | if (location === "all") { 312 | return [ 313 | { 314 | $match: { 315 | keywords: { 316 | $in: serialize(tech), 317 | }, 318 | }, 319 | }, 320 | ]; 321 | } 322 | 323 | // specific specific 324 | return [ 325 | { 326 | $match: { 327 | keywords: { 328 | $in: serialize(tech), 329 | }, 330 | }, 331 | }, 332 | { 333 | $match: { 334 | keywords: { 335 | $in: serialize(location), 336 | }, 337 | }, 338 | }, 339 | ]; 340 | }; 341 | 342 | const serialize = (query) => [new RegExp(query.replaceAll("-", " "))]; 343 | 344 | export const getAllSlugs = async () => { 345 | const { db } = await connectToDatabase(); 346 | 347 | const cursor = await db 348 | .collection("jobs") 349 | .find() 350 | .project({ slug: 1 }) 351 | .toArray(); 352 | 353 | const jobs = JSON.parse(JSON.stringify(cursor)); 354 | 355 | return { jobs }; 356 | }; 357 | 358 | export const getJobBySlug = async (slug) => { 359 | const { db } = await connectToDatabase(); 360 | 361 | const cursor = await db.collection("jobs").findOne({ slug }); 362 | 363 | const job = JSON.parse(JSON.stringify(cursor)); 364 | 365 | return { job }; 366 | }; 367 | 368 | export const addRemoteTag = async () => { 369 | const { db } = await connectToDatabase(); 370 | 371 | const fetch = JSON.parse( 372 | JSON.stringify( 373 | await db 374 | .collection("jobs") 375 | .find() 376 | .project({ createdAt: 1, "schema.datePosted": 1 }) 377 | .toArray() 378 | ) 379 | ); 380 | 381 | const promises = fetch.map((s) => { 382 | const postedAt = Boolean(s?.schema?.datePosted) 383 | ? s?.schema?.datePosted 384 | : s?.createdAt; 385 | return db.collection("jobs").update( 386 | { 387 | _id: ObjectId(s._id), 388 | }, 389 | { 390 | $set: { 391 | postedAt, 392 | }, 393 | } 394 | ); 395 | }); 396 | 397 | // await Promise.all(promises); 398 | 399 | return { fetch }; 400 | }; 401 | 402 | export const getJobCount = async () => { 403 | const { db } = await connectToDatabase(); 404 | 405 | const cursor = await db.collection("job-count").find().limit(10000).toArray(); 406 | const jobCount = JSON.parse(JSON.stringify(cursor)); 407 | return { jobCount }; 408 | }; 409 | 410 | export const getTotalJobsCount = async () => { 411 | const { db } = await connectToDatabase(); 412 | const count = await db.collection("jobs").countDocuments(); 413 | 414 | return { count }; 415 | }; 416 | -------------------------------------------------------------------------------- /pages/jobs/[slug].js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import Head from "next/head"; 3 | import unescape from "unescape"; 4 | import NextLink from "next/link"; 5 | import { 6 | Badge, 7 | Box, 8 | Breadcrumb, 9 | BreadcrumbItem, 10 | BreadcrumbLink, 11 | Button, 12 | Flex, 13 | Heading, 14 | HStack, 15 | Link, 16 | Tag, 17 | Text, 18 | VStack, 19 | } from "@chakra-ui/react"; 20 | import { format } from "date-fns"; 21 | import queryString from "query-string"; 22 | 23 | import { checkIfThisWeek } from "../../helpers/checkIfThisWeek"; 24 | import { siteDescription } from "../../constants/SEO"; 25 | import { getAllSlugs, getJobBySlug } from "../../controllers/jobs"; 26 | import { JOB_EXPERIENCE_TEXT, JOB_TYPE_TEXT } from "../../types/jobs"; 27 | import PinIcon from "../../icons/PinIcon"; 28 | import CalendarIcon from "../../icons/CalendarIcon"; 29 | import ChakraMarkdown from "../../components/ChakraMarkdown"; 30 | import BriefcaseIcon from "../../icons/BriefcaseIcon"; 31 | import ClockIcon from "../../icons/ClockIcon"; 32 | import CashIcon from "../../icons/CashIcon"; 33 | 34 | export const getStaticProps = async (context) => { 35 | const { slug } = context.params; 36 | const { job } = await getJobBySlug(slug); 37 | 38 | return { 39 | props: { job, slug }, 40 | // revalidate every 2 hour 41 | revalidate: 60 * 60 * 2, 42 | }; 43 | }; 44 | 45 | export async function getStaticPaths() { 46 | const { jobs } = await getAllSlugs(); 47 | const paths = jobs.map((j) => ({ params: { slug: j.slug } })); 48 | 49 | return { 50 | paths, 51 | fallback: true, 52 | }; 53 | } 54 | 55 | function ApplyButton({ link, email }) { 56 | const href = link ? link : `mailto:${email}`; 57 | return ( 58 | 59 | 68 | 69 | Please mention that you found the job on Kerja-IT.com, this helps us get 70 | more companies to post here. Thanks. 71 | 72 | 73 | ); 74 | } 75 | 76 | function JobDescription({ job, slug }) { 77 | const jobLink = job?.link || job?.application?.url; 78 | const jobFromAd = job?.source === "ad"; 79 | const jobTitle = 80 | (job?.schema?.title ?? job?.title) || 81 | "Opps... Can't find the job you're looking for"; 82 | 83 | const jobExperience = job?.experience; 84 | 85 | const employmentType = job?.schema?.employmentType; 86 | const isEmploymentTypeArray = Array.isArray(employmentType); 87 | 88 | const employmentTypeValue = isEmploymentTypeArray 89 | ? employmentType.join(", ") 90 | : employmentType; 91 | 92 | const employmentTypeText = 93 | (employmentTypeValue ?? "")?.replaceAll("_", " ").toLowerCase() || 94 | JOB_TYPE_TEXT[job?.type] || 95 | "Unspecified"; 96 | 97 | const companyName = 98 | job?.company?.name || job?.schema?.hiringOrganization?.name; 99 | const companyUrl = job?.company?.website ?? undefined; 100 | const datePosted = job?.postedAt; 101 | const jobDescription = 102 | job?.schema?.responsibilities || job?.schema?.description; 103 | const jobAdType = job?.location?.type; 104 | const jobAdLocation = job?.location?.city + ", " + job?.location?.state; 105 | 106 | const getJobLocation = () => { 107 | switch (jobAdType) { 108 | case 1: 109 | return "Full Remote"; 110 | case 2: 111 | return jobAdLocation; 112 | case 3: 113 | return jobAdLocation + " (Hybrid)"; 114 | default: 115 | return ( 116 | job?.schema?.jobLocation?.address?.stressAddress || 117 | job?.schema?.jobLocation?.address?.addressLocality || 118 | job?.schema?.jobLocation?.address?.addressRegion 119 | ); 120 | } 121 | }; 122 | const jobLocation = getJobLocation(); 123 | 124 | const pageTitleWithoutBrand = companyName 125 | ? `${jobTitle} – ${companyName}` 126 | : jobTitle; 127 | 128 | const pageTitle = pageTitleWithoutBrand + " | Kerja IT 🇲🇾"; 129 | const thisWeek = checkIfThisWeek(job?.schema?.datePosted ?? job?.postedAt); 130 | 131 | const og = queryString.stringifyUrl( 132 | { 133 | url: "https://kerja-it.com/api/og", 134 | query: { 135 | title: jobTitle ? jobTitle : null, 136 | company: companyName ? companyName : null, 137 | }, 138 | }, 139 | { skipEmptyString: true, skipNull: true } 140 | ); 141 | 142 | const salary = job?.salary; 143 | 144 | return ( 145 | 146 | 147 | {pageTitle} 148 | 149 | 150 | 151 | 152 | 153 | 154 | 155 | 156 | 157 | {job && ( 158 | 159 | 160 | Jobs 161 | 162 | 163 | 164 | 165 | {pageTitleWithoutBrand} 166 | 167 | 168 | 169 | )} 170 | {!job && ( 171 | 180 | 😵‍💫 Opps... 181 | 182 | Can't fint the job you're looking for. 183 | 184 | 185 | 186 | Look for other jobs 187 | 188 | or 189 | 197 | 198 | 199 | )} 200 | {job && ( 201 |
202 | 212 | 213 | {job?.keywords.map((keyword) => ( 214 | 215 | {keyword} 216 | 217 | ))} 218 | 219 | 220 | {jobTitle} 221 | 222 | 223 | {companyName && companyUrl && ( 224 | 225 | {companyName} 226 | 227 | )} 228 | {companyName && !companyUrl && ( 229 | {companyName} 230 | )} 231 | 232 | 233 | 234 | {datePosted 235 | ? "Posted on " + format(new Date(datePosted), "do MMM yyyy") 236 | : "Unspecified"} 237 | 238 | {thisWeek && New} 239 | 240 | 241 | 242 | {jobLocation ?? "Unspecified"} 243 | 244 | 245 | 246 | 247 | {employmentTypeText} 248 | 249 | 250 | 251 | 252 | 253 | {JOB_EXPERIENCE_TEXT[jobExperience] ?? "Unspecified"} 254 | 255 | 256 | {!!salary && ( 257 | 258 | 259 | 260 | {new Intl.NumberFormat("en-UK", { 261 | style: "currency", 262 | currency: "MYR", 263 | }).format(salary)} 264 | 265 | 266 | )} 267 | 268 | 269 | 270 | 271 | 272 | 273 | ✍️ Job Description 274 | 275 | {jobFromAd ? ( 276 | 277 | 278 | {job?.description.replaceAll("\\n", "\n")} 279 | 280 | 281 | ) : ( 282 | 290 | )} 291 | 292 | 293 | 294 | 295 | 296 | 297 | 298 | Applied but no response? Drop your resume here 📥 299 | 300 | 301 |
302 | )} 303 |
304 | ); 305 | } 306 | 307 | export default JobDescription; 308 | -------------------------------------------------------------------------------- /pages/api/og.jsx: -------------------------------------------------------------------------------- 1 | import { ImageResponse } from "@vercel/og"; 2 | 3 | export const config = { 4 | runtime: "experimental-edge", 5 | }; 6 | 7 | export default function handler(req) { 8 | try { 9 | const { searchParams } = new URL(req.url); 10 | const paramsTitle = searchParams.get("title"); 11 | const paramsCompany = searchParams.get("company"); 12 | 13 | const title = paramsTitle 14 | ? decodeURIComponent(paramsTitle?.slice(0, 100)) 15 | : null; 16 | const company = paramsCompany 17 | ? decodeURIComponent(paramsCompany?.slice(0, 100)) 18 | : null; 19 | 20 | return new ImageResponse( 21 | ( 22 |
36 |
37 | 44 | 48 | 49 | 53 | 54 |
55 |
62 | {title} 63 |
64 | {company && ( 65 |
66 | {company} 67 |
68 | )} 69 |
70 | ), 71 | { 72 | width: 1200, 73 | height: 630, 74 | } 75 | ); 76 | } catch (e) { 77 | console.log(`${e.message}`); 78 | return new Response(`Failed to generate the image`, { 79 | status: 500, 80 | }); 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /pages/profile.js: -------------------------------------------------------------------------------- 1 | import React, { useState } from "react"; 2 | import axios from "axios"; 3 | import { format } from "date-fns"; 4 | import { 5 | Box, 6 | Button, 7 | Checkbox, 8 | Flex, 9 | Heading, 10 | Input, 11 | InputGroup, 12 | InputLeftAddon, 13 | ListItem, 14 | Radio, 15 | RadioGroup, 16 | Select, 17 | Stack, 18 | Text, 19 | Textarea, 20 | UnorderedList, 21 | useCheckboxGroup, 22 | } from "@chakra-ui/react"; 23 | import supertokensNode from "supertokens-node"; 24 | import Session from "supertokens-node/recipe/session"; 25 | import { SessionAuth } from "supertokens-auth-react/recipe/session"; 26 | import { useRouter } from "next/router"; 27 | 28 | import { places } from "../constants/paths"; 29 | import { getUserBySessionId } from "../controllers/users"; 30 | import { backendConfig } from "../config/backendConfig"; 31 | import SectionContainer from "../components/SectionContainer"; 32 | 33 | export const getServerSideProps = async (context) => { 34 | supertokensNode.init(backendConfig()); 35 | let session; 36 | 37 | try { 38 | session = await Session.getSession(context.req, context.res); 39 | } catch (err) { 40 | if (err.type === Session.Error.TRY_REFRESH_TOKEN) { 41 | return { props: { fromSupertokens: "needs-refresh" } }; 42 | } 43 | 44 | if (err.type === Session.Error.UNAUTHORISED) { 45 | return { 46 | redirect: { 47 | permanent: false, 48 | destination: "/auth?redirectToPath=profile", 49 | }, 50 | }; 51 | } 52 | } 53 | 54 | try { 55 | const userId = session.getUserId(); 56 | const raw = await getUserBySessionId(userId); 57 | const data = JSON.parse(JSON.stringify(raw.user)); 58 | 59 | const { __v, createdAt, updatedAt, ...rest } = data; 60 | 61 | return { 62 | props: { 63 | user: rest, 64 | }, 65 | }; 66 | } catch (error) { 67 | console.log(error); 68 | return { 69 | redirect: { 70 | permanent: false, 71 | destination: "/auth?redirectToPath=profile", 72 | }, 73 | }; 74 | } 75 | }; 76 | 77 | function Profile({ user }) { 78 | const router = useRouter(); 79 | const [loading, setLoading] = useState(false); 80 | const [profile, setProfile] = useState(user); 81 | 82 | const { 83 | _id, 84 | headline, 85 | name, 86 | email, 87 | phone, 88 | city, 89 | state, 90 | bio, 91 | positions, 92 | status, 93 | jobTypes, 94 | jobLevels, 95 | arrangements, 96 | locations, 97 | availableDate, 98 | website, 99 | github, 100 | linkedin, 101 | } = profile; 102 | 103 | const isRequired = ["active", "open"].some((s) => s === status); 104 | 105 | const positionsCheckbox = useCheckboxGroup({ defaultValue: positions }); 106 | const jobTypesCheckbox = useCheckboxGroup({ defaultValue: jobTypes }); 107 | const jobLevelsCheckbox = useCheckboxGroup({ defaultValue: jobLevels }); 108 | const arrangementsCheckbox = useCheckboxGroup({ defaultValue: arrangements }); 109 | const locationsCheckbox = useCheckboxGroup({ defaultValue: locations }); 110 | 111 | const onChangeText = (e) => 112 | setProfile({ ...profile, [e.target.name]: e.target.value }); 113 | const onChangeCheckbox = (e) => 114 | setProfile({ ...profile, [e.target.name]: e.target.checked }); 115 | const onChangeRadio = (name, value) => 116 | setProfile({ ...profile, [name]: value }); 117 | 118 | const states = places 119 | .filter((p) => p !== "remote") 120 | .map((p) => p.replaceAll("-", " ")); 121 | 122 | const onSubmit = async (e) => { 123 | e.preventDefault(); 124 | setLoading(true); 125 | const newProfile = { 126 | ...profile, 127 | positions: positionsCheckbox.value, 128 | jobTypes: jobTypesCheckbox.value, 129 | jobLevels: jobLevelsCheckbox.value, 130 | arrangements: arrangementsCheckbox.value, 131 | locations: locationsCheckbox.value, 132 | }; 133 | const { data, error } = await axios.post("/api/users", newProfile); 134 | setLoading(false); 135 | alert(data.msg || error.msg); 136 | router.push(`/talents/${_id}`); 137 | }; 138 | 139 | return ( 140 | 141 | 142 | 143 | Developer Profile 144 | 145 | 146 | Personal info 147 | 148 | Headline 149 | 156 | 157 | 158 | Name 159 | 165 | 166 | 167 | Email 168 | 169 | 170 | 171 | Phone 172 | 173 | +60 174 | 182 | 183 | 184 | 185 | City 186 | 193 | 194 | 195 | State 196 | 210 | 211 | 212 | 213 | Your skills 214 | 215 | Bio 216 | 217 | Share a few paragraphs on what makes you unique as a developer. 218 | This is your chance to market yourself to potential employers – 219 | sell yourself a little! 220 | 221 | EXAMPLE TOPICS 222 | 223 | Projects you involved in 224 | Your proudest achievements 225 | Any freelancing experience 226 | Side-projects you build 227 | Any recent milestones or accomplishments 228 | How you can help where others can't 229 | 230 |