├── .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 |
--------------------------------------------------------------------------------
/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 |
16 | );
17 | case "angular":
18 | return (
19 |
25 | );
26 | case "node-js":
27 | return (
28 |
34 | );
35 | case "flutter":
36 | return (
37 |
43 | );
44 | case "laravel":
45 | return (
46 |
52 | );
53 | case "vue":
54 | return (
55 |
61 | );
62 | case "django":
63 | return (
64 |
70 | );
71 | case "kotlin":
72 | return (
73 |
79 | );
80 | case "ruby-on-rails":
81 | return (
82 |
88 | );
89 | case "webflow":
90 | return (
91 |
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 |
13 | );
14 | case "kedah":
15 | return (
16 |
21 | );
22 | case "kelantan":
23 | return (
24 |
29 | );
30 | case "kuala-lumpur":
31 | return (
32 |
37 | );
38 | case "labuan":
39 | return (
40 |
45 | );
46 | case "melaka":
47 | return (
48 |
53 | );
54 | case "negeri-sembilan":
55 | return (
56 |
61 | );
62 | case "pahang":
63 | return (
64 |
69 | );
70 | case "perak":
71 | return (
72 |
77 | );
78 | case "perlis":
79 | return (
80 |
85 | );
86 | case "penang":
87 | case "pulau-pinang":
88 | return (
89 |
94 | );
95 | case "putrajaya":
96 | return (
97 |
102 | );
103 | case "sabah":
104 | return (
105 |
110 | );
111 | case "sarawak":
112 | return (
113 |
118 | );
119 | case "selangor":
120 | return (
121 |
126 | );
127 | case "terengganu":
128 | return (
129 |
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 |
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 |
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 |
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 |
238 |
239 |
240 | Preferred Positions
241 |
242 |
247 | Frontend Developer
248 |
249 |
254 | Backend Developer
255 |
256 |
261 | Fullstack Developer
262 |
263 |
264 |
265 |
266 |
267 | Work preferences
268 |
269 | Search status
270 | onChangeRadio("status", e)}
275 | >
276 |
277 |
278 | Actively looking
279 |
280 |
281 | Open to opportunities
282 |
283 |
284 | Not interested
285 |
286 | Only people with links can see you profile.
287 |
288 |
289 |
290 | Invisible
291 |
292 | Your profile is hidden and can only be seen by yourself.
293 |
294 |
295 |
296 |
297 |
298 |
299 | Role Type
300 |
301 | Select all roles that you would consider taking.
302 |
303 |
304 |
309 | Full-time employment
310 |
311 |
316 | Contract
317 |
318 |
323 | Freelance
324 |
325 |
326 |
327 |
328 | Role Level
329 |
330 | Select all the experience levels you would consider taking.
331 |
332 |
333 |
338 | Junior
339 |
340 |
345 | Mid-level
346 |
347 |
352 | Senior
353 |
354 |
359 | Principal / Staff
360 |
361 |
366 | C-level
367 |
368 |
369 |
370 |
371 | Working Arrangements
372 | Select all if you are open for hybrid.
373 |
374 |
379 | Remote
380 |
381 |
386 | On-site
387 |
388 |
389 |
390 |
391 | Work Location
392 | Select all locations you are available.
393 |
394 | {states.map((p) => (
395 |
401 | {p}
402 |
403 | ))}
404 |
405 |
406 |
407 | Available to start on
408 |
419 |
420 |
421 |
422 | Online presence
423 |
424 | Website
425 |
426 | https://
427 |
428 |
429 | Your personal website or portfolio
430 |
431 |
432 | GitHub
433 |
434 | github.com/
435 |
436 |
437 |
438 |
439 | LinkedIn
440 |
441 | linkedin.com/in/
442 |
443 |
444 |
445 |
446 | {/*
447 | Email notifications
448 |
449 |
456 | Get weekly job alerts via email
457 |
458 |
459 | */}
460 |
461 |
470 |
471 |
472 |
473 | );
474 | }
475 |
476 | export default Profile;
477 |
--------------------------------------------------------------------------------