diff --git a/components/NavBar.jsx b/components/NavBar.jsx index 4ee83eb..0473612 100644 --- a/components/NavBar.jsx +++ b/components/NavBar.jsx @@ -3,6 +3,7 @@ import { Fragment } from 'react'; import { useSession, signOut } from 'next-auth/react'; import { Menu, Transition } from '@headlessui/react'; import { useEffect, useState } from 'react'; +import { Paths } from '@lib/globals'; export default function NavBar({ pathname }) { const { data: session } = useSession(); @@ -31,7 +32,7 @@ export default function NavBar({ pathname }) { return ( - ); -} - -function LandingPage() { - return ( -
- - About bResearch - -
- {/* Navigation bar with BResearch logo and Login/Signup button */} -
- - {/* First Section */} -
-
-
-
-

- Countless Research Opportunities, -
- All In One Place -

-

- bResearch is a new web app that connects UCLA -
- students to research opportunities, completely free for -
- everyone. -

- - Sign Up - -
-
- Generic Picture -
-
-
- - {/* Second Section */} -
-
-
- Generic Picture -
-
-

- Tired of Cold Emailing Professors? -

-

- bResearch connects your detailed application to every -
- professor—no more lost emails in the pile. -

-
-
-
- - {/* Third Section */} -
-
-
-

- Stay Organized in Your Search -
- For Your Next Research Opportunity. -

-

- We have developed features such as bookmarking, -
- application tracking, and a refined filter to help you -
- optimize your search for your next research -
- opportunity. -

-
-
- Generic Picture -
-
-
- -
- - Create Your Account Now - -
-
- - -
- ); -} - -export default LandingPage; diff --git a/pages/account-type.jsx b/pages/account-type.jsx index 5f00510..5439082 100644 --- a/pages/account-type.jsx +++ b/pages/account-type.jsx @@ -3,6 +3,7 @@ import Head from 'next/head'; import Image from 'next/image'; import { useRouter } from 'next/router'; import { getSession } from 'next-auth/react'; +import { Paths } from '@lib/globals'; function AccountType() { const router = useRouter(); @@ -22,7 +23,7 @@ function AccountType() { const responseBody = await res.json(); if (responseBody?.message?.startsWith('Student al')) { await getSession(); // calls jwt callback to update accountType - await router.push('/'); + await router.push(Paths.PostsPage); } } } catch (e) {} @@ -43,7 +44,7 @@ function AccountType() { const responseBody = await res.json(); if (responseBody?.message?.startsWith('Researcher al')) { await getSession(); // calls jwt callback to update accountType - await router.push('/'); + await router.push(Paths.PostsPage); } } } catch (e) {} diff --git a/pages/api/applications/[jobId]/apply.js b/pages/api/applications/[jobId]/apply.js index 280332b..984774f 100644 --- a/pages/api/applications/[jobId]/apply.js +++ b/pages/api/applications/[jobId]/apply.js @@ -1,6 +1,7 @@ import { Prisma } from 'prisma/prisma-client'; import ApiRoute from '@lib/ApiRoute'; +import { Paths } from '@lib/globals'; class ApplicationsApplyRoute extends ApiRoute { /** @@ -59,7 +60,7 @@ class ApplicationsApplyRoute extends ApiRoute { }, }); await res.revalidate(`/job/${jobId}`); - await res.revalidate('/'); + await res.revalidate(Paths.PostsPage); return res.json(result); } catch (e) { diff --git a/pages/api/jobs/[jobId]/close.js b/pages/api/jobs/[jobId]/close.js index a75f79d..9c3c11b 100644 --- a/pages/api/jobs/[jobId]/close.js +++ b/pages/api/jobs/[jobId]/close.js @@ -1,5 +1,6 @@ import { Prisma } from 'prisma/prisma-client'; import ApiRoute from '@lib/ApiRoute'; +import { Paths } from '@lib/globals'; class CloseJobRoute extends ApiRoute { /** @@ -46,7 +47,7 @@ class CloseJobRoute extends ApiRoute { }); await res.revalidate(`/job/${updatedJob.id}`); - await res.revalidate('/'); + await res.revalidate(Paths.PostsPage); res.status(200).json(updatedJob); } catch (e) { diff --git a/pages/api/jobs/[jobId]/edit.js b/pages/api/jobs/[jobId]/edit.js index 62600ce..4d6fa90 100644 --- a/pages/api/jobs/[jobId]/edit.js +++ b/pages/api/jobs/[jobId]/edit.js @@ -6,6 +6,7 @@ import DOMPurify from 'dompurify'; const window = new JSDOM('').window; const purify = DOMPurify(window); import ApiRoute from '@lib/ApiRoute'; +import { Paths } from '@lib/globals'; class EditJobRoute extends ApiRoute { /** @@ -128,7 +129,7 @@ class EditJobRoute extends ApiRoute { }); await res.revalidate(`/job/${updatedJob.id}`); - await res.revalidate('/'); + await res.revalidate(Paths.PostsPage); res.status(200).json(updatedJob); } catch (e) { diff --git a/pages/api/jobs/create.js b/pages/api/jobs/create.js index 941d272..5810733 100644 --- a/pages/api/jobs/create.js +++ b/pages/api/jobs/create.js @@ -6,6 +6,7 @@ import DOMPurify from 'dompurify'; const window = new JSDOM('').window; const purify = DOMPurify(window); import ApiRoute from '@lib/ApiRoute'; +import { Paths } from '@lib/globals'; /* example POST request body { @@ -123,7 +124,7 @@ class JobCreationRoute extends ApiRoute { }); await res.revalidate(`/job/${result.id}`); - await res.revalidate('/'); + await res.revalidate(Paths.PostsPage); res.status(200).json(result); } catch (e) { diff --git a/pages/index.jsx b/pages/index.jsx index 5cd6753..762772c 100644 --- a/pages/index.jsx +++ b/pages/index.jsx @@ -1,1068 +1,148 @@ -import prisma from '@lib/prisma'; - -import { useState, Fragment, useEffect } from 'react'; -import { Listbox, Dialog, Transition } from '@headlessui/react'; -import { CheckIcon } from '@heroicons/react/20/solid'; -import { useMutation, useQuery } from '@tanstack/react-query'; -import NavBar from '../components/NavBar'; -import { useForm } from 'react-hook-form'; -import { joiResolver } from '@hookform/resolvers/joi'; -import { JobSearchValidator } from '@lib/validators'; +import Image from 'next/image'; +import Link from 'next/link'; import Head from 'next/head'; -import { Departments, Majors } from '@lib/globals'; +import aboutPic1 from '../public/aboutPic1.svg'; +import aboutPic2 from '../public/aboutPic2.svg'; +import aboutPic3 from '../public/aboutPic3.svg'; +import logo from '../public/logo.svg'; -function ResearcherJobCard({ - id, - title, - location, - duration, - departments, - labName, - setSelectedJob, - isSelectedJob, -}) { +function Navbar() { return ( -
-
-
{labName}
- { - - } +
+ ); } -function JobCard({ - id, - title, - location, - duration, - departments, - labName, - setSelectedJob, - updateJob, - isSelectedJob, - toast, -}) { - // Mutations - const mutation = useMutation({ - mutationFn: async () => { - return await ( - await fetch(`/api/applications/${id}/save`, { - method: 'PUT', - }) - ).json(); - }, - onSettled: (data) => { - if (data?.saved) { - updateJob(); - } - }, - }); +function LandingPage() { return ( -
-
-
{labName}
- {mutation.isLoading ? ( - - ) : ( - - )} -
-
{title}
- -
- ); -} - -function SavedJobCard({ - id, - title, - location, - duration, - departments, - labName, - setSelectedJob, - updateJob, - isSelectedJob, - toast, -}) { - // Mutations - const mutation = useMutation({ - mutationFn: async () => { - return await ( - await fetch(`/api/applications/${id}/unsave`, { - method: 'DELETE', - }) - ).json(); - }, - onSettled: (data) => { - if (data?.unsaved) { - updateJob(); - } - }, - }); - return ( -
-
-
{labName}
- {mutation.isLoading ? ( - - ) : ( - - )} -
-
{title}
- -
- ); -} - -const departments = [ - { value: 'HUMANITIES', label: 'Humanities' }, - { value: 'PHYSICAL_SCIENCES', label: 'Physical Sciences' }, - { value: 'LIFE_SCIENCES', label: 'Life Sciences' }, - { value: 'ENGINEERING', label: 'Engineering' }, - { value: 'SOCIAL_SCIENCES', label: 'Social Sciences' }, -]; -const locations = [ - { value: 'ON_CAMPUS', label: 'On Campus' }, - { value: 'OFF_CAMPUS', label: 'Off Campus' }, - { value: 'REMOTE', label: 'Remote' }, -]; -const durations = [ - { value: 'QUARTERLY', label: 'Quarterly' }, - { value: 'SUMMER', label: 'Summer' }, - { value: 'ACADEMIC_YEAR', label: 'Academic Year' }, - { value: 'YEAR_ROUND', label: 'Year Round' }, -]; -const payRanges = [ - { value: true, label: 'Paid' }, - { value: false, label: 'Unpaid' }, -]; - -function Home({ jobs: originalJobs }) { - const { isLoading, isError, data, status } = useQuery({ - queryKey: ['applications'], - queryFn: async () => { - return await (await fetch('/api/applications')).json(); - }, - refetchOnMount: false, - refetchOnReconnect: false, - refetchOnWindowFocus: false, - cacheTime: 0, // don't cache. TODO: decide on what to to for researchers: maybe don't fetch at all - }); - - const [jobs, setJobs] = useState(null); - const [selectedDepartments, setSelectedDepartments] = useState([]); - const [selectedDurations, setSelectedDurations] = useState([]); - const [selectedPayRanges, setSelectedPayRanges] = useState([]); - const [selectedLocations, setSelectedLocations] = useState([]); - const [selectedJobID, setSelectedJobID] = useState(null); - const [copiedExternalLink, setCopiedExternalLink] = useState(false); - - const [accountType, setAccountType] = useState(null); - - useEffect(() => { - const storedAccountType = localStorage.getItem('accountType'); - if (storedAccountType === 'researcher' || storedAccountType === 'student') { - setAccountType(storedAccountType); - } else { - async function getAccountType() { - try { - const accType = (await (await fetch('/api/account-type')).json()).accountType; - // localStorage.setItem('accountType', accType); // let NavBar set accountType in localStorage - setAccountType(accType); - } catch (err) { - setAccountType('student'); // default to student // TODO: improve this when fetch fails - } - } - getAccountType(); - } - }, []); - - const [isFiltersDialogOpen, setIsFiltersDialogOpen] = useState(!true); - - const [isReversed, setIsReversed] = useState(false); - useEffect(() => { - if (status !== 'success') { - return; - } - const markedJobs = data.map(({ job: { id } }) => id); - const unmarkedJobs = originalJobs - .filter(({ id }) => !markedJobs.includes(id)) - .map((j) => ({ ...j, status: null })); // .map((j) => ({ ...j, saved: false })); - setJobs(unmarkedJobs); - setSelectedJobID(unmarkedJobs[0]?.id); - }, [data, status, originalJobs]); - - const { - register, - handleSubmit, - formState: { errors }, - } = useForm({ - resolver: joiResolver(JobSearchValidator), - }); - - const appliedMutation = useMutation({ - mutationFn: async () => { - return await ( - await fetch(`/api/applications/${selectedJobID}`, { - method: 'PUT', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - status: 'APPLIED', - }), - }) - ).json(); - }, - onSettled: (data) => { - if (data?.lastUpdated) { - setCopiedExternalLink(false); - } - }, - }); - // useEffect(() => setCopiedExternalLink(false), [selectedJobID]); - - useEffect(() => { - if (errors?.jobSearchQuery?.type === 'string.empty') { - setJobs(originalJobs); - } - }, [errors]); - - let filteredJobs = jobs ? [...jobs] : []; - if (selectedPayRanges.length !== 0 && selectedPayRanges.length !== departments.length) { - filteredJobs = filteredJobs.filter(({ paid }) => selectedPayRanges.includes(paid)); - } - if (selectedLocations.length !== 0 && selectedLocations.length !== locations.length) { - filteredJobs = filteredJobs.filter(({ location }) => selectedLocations.includes(location)); - } - if (selectedDurations.length !== 0 && selectedDurations.length !== durations.length) { - filteredJobs = filteredJobs.filter(({ duration }) => selectedDurations.includes(duration)); - } - if (selectedDepartments.length !== 0 && selectedDepartments.length !== departments.length) { - filteredJobs = filteredJobs.filter(({ departments }) => - departments.some((item) => selectedDepartments.includes(item)) - ); - } - if (isReversed) { - filteredJobs.reverse(); - } - - const selectedJob = jobs && jobs.find(({ id }) => id === selectedJobID); - - const dateFormatter = new Intl.DateTimeFormat('en-US', { - month: 'long', - day: 'numeric', - year: 'numeric', - }); - - return ( -
+
- bResearch + About bResearch - -
-
-
-
-
{ - try { - const response = await fetch( - `/api/jobs/search?jobSearchQuery=${d.jobSearchQuery}` - ); - if (response.ok) { - const jobs = await response.json(); - setJobs(jobs); - } - } catch (e) {} - })} +
+ {/* Navigation bar with BResearch logo and Login/Signup button */} +
+ + {/* First Section */} +
+
+
+
+

+ Countless Research Opportunities, +
+ All In One Place +

+

+ bResearch is a new web app that connects UCLA +
+ students to research opportunities, completely free for +
+ everyone. +

+ - - {/* TODO: put svg in button and make it submit form */} - - - - - - -
- {/* https://www.w3docs.com/snippets/css/how-to-set-absolute-positioning-relative-to-the-parent-element.html */} - - - Department - - - {departments.map(({ value, label }) => ( - - {({ active, selected }) => ( -
  • - - {label} -
  • - )} -
    - ))} -
    -
    -
    -
    - - - Duration - - - {durations.map(({ value, label }) => ( - - {({ active, selected }) => ( -
  • - - {label} -
  • - )} -
    - ))} -
    -
    -
    -
    - - - Pay - - - {payRanges.map(({ value, label }) => ( - - {({ active, selected }) => ( -
  • - - {label} -
  • - )} -
    - ))} -
    -
    -
    -
    - - - Locations - - - {locations.map(({ value, label }) => ( - - {({ active, selected }) => ( -
  • - - {label} -
  • - )} -
    - ))} -
    -
    -
    - + Sign Up + +
    +
    + Generic Picture
    - {jobs !== null && !isLoading && !isError && ( -
    -
    - {/* TODO: maybe add overflow-y-scroll and correct height above */} - {filteredJobs.length ? ( - filteredJobs.map( - ({ - id, - title, - departments, - duration, - location, - lab: { name: labName }, - status, - }) => { - if (accountType === 'researcher') { - return ( - { - setSelectedJobID(id); - setCopiedExternalLink(false); - }} - isSelectedJob={selectedJobID === id} - /> - ); - } - return status === 'SAVED' ? ( - - setJobs(jobs.map((j) => (j.id === id ? { ...j, status: null } : j))) - } - labName={labName} - title={title} - departments={departments} - duration={duration} - location={location} - setSelectedJob={() => { - setSelectedJobID(id); - setCopiedExternalLink(false); - }} - isSelectedJob={selectedJobID === id} - /> - ) : ( - - setJobs(jobs.map((j) => (j.id === id ? { ...j, status: 'SAVED' } : j))) - } - labName={labName} - title={title} - departments={departments} - duration={duration} - location={location} - setSelectedJob={() => { - setSelectedJobID(id); - setCopiedExternalLink(false); - }} - isSelectedJob={selectedJobID === id} - /> - ); - } - // id, updateJob, title, description, duration, toast, departments/> - ) - ) : ( -
    There are zero unsaved jobs that match these filters
    - )} -
    - {selectedJob && ( -
    - <> -
    {selectedJob.lab.name}
    -
    {selectedJob.title}
    -
    -
    Posted on {dateFormatter.format(new Date(selectedJob.created))}
    - -
    - {selectedJob._count.applicants} Applicant - {selectedJob._count.applicants !== 1 && 's'} -
    -
    -
    - {[ - { - field: 'Department', - value: - departments.find(({ value }) => value === selectedJob.departments[0]) - ?.label ?? '', - }, - { - field: 'Location', - value: locations.find(({ value }) => value === selectedJob.location) - .label, - }, - // { field: 'Position Type', value: '' }, - { - field: 'Desired Start Date', - value: new Intl.DateTimeFormat('en-US', { dateStyle: 'medium' }).format( - new Date(selectedJob.startDate) - ), - }, - // { field: 'Research Area', value: '' }, - { - field: 'Duration', - value: durations.find(({ value }) => value === selectedJob.duration) - .label, - }, - { - field: 'Approved for Credit', - value: selectedJob.credit === null ? 'No' : selectedJob.credit || 'Yes', - }, - ].map(({ field, value }) => ( -
    -
    {field}
    -
    {value}
    -
    - ))} -
    - {accountType === 'student' && ( -
    - {selectedJob.status === 'SAVED' ? ( - - ) : ( - - )} - {selectedJob.externalLink ? ( - <> - - {copiedExternalLink && ( -
    -
    - Copied Application{' '} - {selectedJob.externalLink.startsWith('http') ? 'Link' : 'Email'}. - Did you apply? -
    - -
    - )} - - ) : ( - <> - - - )} -
    - )} -
    -
    - Approximate hours per week:{' '} - {selectedJob.weeklyHours} -
    -
    - Applications Accepted Until:{' '} - {dateFormatter.format(new Date(selectedJob.closingDate))} -
    -
    -
    Job Description:
    -
    -
    -
    - -
    - )} +
    + + {/* Second Section */} +
    +
    +
    + Generic Picture
    - )} -
    -
    - - setIsFiltersDialogOpen(false)}> - {/* TODO: Add font https://stackoverflow.com/questions/75422265/next-font-works-everywhere-except-one-specific-component */} - -
    - - -
    -
    - - - - All Filters - -
    -
    -
    Departments
    - {departments.map(({ value, label }) => { - const updateFilter = () => - setSelectedDepartments( - selectedDepartments.includes(value) - ? selectedDepartments.filter((d) => d !== value) - : [...selectedDepartments, value] - ); - return ( -
    - - -
    - ); - })} -
    -
    -
    Duration
    - {durations.map(({ value, label }) => { - const updateFilter = () => - setSelectedDurations( - selectedDurations.includes(value) - ? selectedDurations.filter((duration) => duration !== value) - : [...selectedDurations, value] - ); - return ( -
    - - -
    - ); - })} -
    - -
    -
    Pay
    - {payRanges.map(({ value, label }) => { - const updateFilter = () => - setSelectedPayRanges( - selectedPayRanges.includes(value) - ? selectedPayRanges.filter((p) => p !== value) - : [...selectedPayRanges, value] - ); - return ( -
    - - -
    -
    - ); - })} -
    -
    -
    Locations
    - {locations.map(({ value, label }) => { - const updateFilter = () => - setSelectedLocations( - selectedLocations.includes(value) - ? selectedLocations.filter((location) => location !== value) - : [...selectedLocations, value] - ); - return ( -
    - - -
    - ); - })} -
    -
    - -
    - -
    -
    -
    +
    +

    + Tired of Cold Emailing Professors? +

    +

    + bResearch connects your detailed application to every +
    + professor—no more lost emails in the pile. +

    -
    -
    -
    - ); -} + + + {/* Third Section */} +
    +
    +
    +

    + Stay Organized in Your Search +
    + For Your Next Research Opportunity. +

    +

    + We have developed features such as bookmarking, +
    + application tracking, and a refined filter to help you +
    + optimize your search for your next research +
    + opportunity. +

    +
    +
    + Generic Picture +
    +
    +
    -export async function getStaticProps() { - const jobs = await prisma.job.findMany({ - select: { - id: true, - title: true, - description: true, - departments: true, - duration: true, - location: true, - lab: { select: { name: true } }, - created: true, - startDate: true, - closingDate: true, - credit: true, - weeklyHours: true, - paid: true, - externalLink: true, - _count: { - select: { - applicants: { where: { status: 'APPLIED' } }, - }, - }, - }, - where: { closed: false, closingDate: { gt: new Date() } }, - take: 50, - }); +
    + + Create Your Account Now + +
    +
    - return { - props: { - jobs: jobs.map((job) => ({ - ...job, - created: JSON.parse(JSON.stringify(job.created)), - closingDate: JSON.parse(JSON.stringify(job.closingDate)), - startDate: JSON.parse(JSON.stringify(job.startDate)), - })), - }, - }; +
    + {/*

    FAQ|Support Us|Contact +
    + Instagram|Facebook|Twitter|Github +

    */} + {/* Footer content with FAQ|Support Us|Contact|Instagram|Facebook|Twitter|Github*/} +
    +
    + ); } -export default Home; +export default LandingPage; diff --git a/pages/job/[jobId]/edit.jsx b/pages/job/[jobId]/edit.jsx index 7a2982d..f4672f5 100644 --- a/pages/job/[jobId]/edit.jsx +++ b/pages/job/[jobId]/edit.jsx @@ -9,7 +9,7 @@ import 'react-quill/dist/quill.snow.css'; import ResearcherSidebar from '../../../components/ResearcherSidebar'; import DatePicker from 'react-datepicker'; import 'react-datepicker/dist/react-datepicker.css'; -import { Departments, Durations } from '@lib/globals'; +import { Departments, Durations, Paths } from '@lib/globals'; // https://github.com/zenoamaro/react-quill/issues/718#issuecomment-873541445 // https://github.com/zenoamaro/react-quill/issues/596#issuecomment-1207420071 @@ -136,7 +136,7 @@ function EditJobPosting() { method: 'PATCH', }); if (res.status === 200) { - await router.push('/posts'); + await router.push(Paths.ManagePostsPage); } } catch (e) {} setIsSubmitting(false); @@ -171,7 +171,7 @@ function EditJobPosting() { }, }); if (res.status === 200) { - await router.push('/posts'); + await router.push(Paths.ManagePostsPage); } } catch (e) {} setIsSubmitting(false); diff --git a/pages/job/create.jsx b/pages/job/create.jsx index 047ce6c..9cef034 100644 --- a/pages/job/create.jsx +++ b/pages/job/create.jsx @@ -9,7 +9,7 @@ import 'react-quill/dist/quill.snow.css'; import ResearcherSidebar from '../../components/ResearcherSidebar'; import DatePicker from 'react-datepicker'; import 'react-datepicker/dist/react-datepicker.css'; -import { Departments, Durations } from '@lib/globals'; +import { Departments, Durations, Paths } from '@lib/globals'; // https://github.com/zenoamaro/react-quill/issues/718#issuecomment-873541445 // https://github.com/zenoamaro/react-quill/issues/596#issuecomment-1207420071 @@ -129,7 +129,7 @@ function CreateJobPosting() { }, }); if (res.status === 200) { - await router.push('/posts'); + await router.push(Paths.ManagePostsPage); } } catch (e) {} setIsSubmitting(false); diff --git a/pages/manage-posts.jsx b/pages/manage-posts.jsx new file mode 100644 index 0000000..276fba0 --- /dev/null +++ b/pages/manage-posts.jsx @@ -0,0 +1,87 @@ +import { useState, useEffect } from 'react'; +import ResearcherSidebar from '../components/ResearcherSidebar'; +import ResearcherPostCard from '../components/ResearcherPostCard'; + +import Head from 'next/head'; +import Link from 'next/link'; + +function ResearcherPosts() { + const [posts, setPosts] = useState(null); + + useEffect(() => { + async function getPosts() { + try { + const posts = await (await fetch('/api/researcher/posts')).json(); + // console.log({ posts }); + setPosts(posts); + } catch (e) {} + } + getPosts(); + }, []); + return ( + <> + + Manage Posts + + +
    +
    + + + + + + + New Post + +
    + {posts !== null && ( +
    + {posts.length === 0 ? ( +
    You have no posts.
    + ) : ( + posts.map( + ({ + id, + lab: { name: labName }, + title, + closingDate, + _count: { applicants: applicantCount }, + }) => ( + + ) + ) + )} +
    + )} +
    + + ); +} + +export default ResearcherPosts; diff --git a/pages/posts.jsx b/pages/posts.jsx index 276fba0..9b19397 100644 --- a/pages/posts.jsx +++ b/pages/posts.jsx @@ -1,87 +1,1068 @@ -import { useState, useEffect } from 'react'; -import ResearcherSidebar from '../components/ResearcherSidebar'; -import ResearcherPostCard from '../components/ResearcherPostCard'; +import prisma from '@lib/prisma'; +import { useState, Fragment, useEffect } from 'react'; +import { Listbox, Dialog, Transition } from '@headlessui/react'; +import { CheckIcon } from '@heroicons/react/20/solid'; +import { useMutation, useQuery } from '@tanstack/react-query'; +import NavBar from '../components/NavBar'; +import { useForm } from 'react-hook-form'; +import { joiResolver } from '@hookform/resolvers/joi'; +import { JobSearchValidator } from '@lib/validators'; import Head from 'next/head'; -import Link from 'next/link'; +import { Departments, Majors, Paths } from '@lib/globals'; -function ResearcherPosts() { - const [posts, setPosts] = useState(null); +function ResearcherJobCard({ + id, + title, + location, + duration, + departments, + labName, + setSelectedJob, + isSelectedJob, +}) { + return ( +
    +
    +
    {labName}
    + { + + } +
    +
    {title}
    +
      +
    • + {Departments.find(({ value }) => value === departments[0]).label} +
    • +
    • + {locations.find(({ value }) => value === location).label} +
    • +
    +
    + ); +} - useEffect(() => { - async function getPosts() { - try { - const posts = await (await fetch('/api/researcher/posts')).json(); - // console.log({ posts }); - setPosts(posts); - } catch (e) {} - } - getPosts(); - }, []); +function JobCard({ + id, + title, + location, + duration, + departments, + labName, + setSelectedJob, + updateJob, + isSelectedJob, + toast, +}) { + // Mutations + const mutation = useMutation({ + mutationFn: async () => { + return await ( + await fetch(`/api/applications/${id}/save`, { + method: 'PUT', + }) + ).json(); + }, + onSettled: (data) => { + if (data?.saved) { + updateJob(); + } + }, + }); return ( - <> - - Manage Posts - - -
    -
    - +
    +
    {labName}
    + {mutation.isLoading ? ( + + ) : ( + + )} +
    +
    {title}
    +
      +
    • + {Departments.find(({ value }) => value === departments[0]).label} +
    • +
    • + {locations.find(({ value }) => value === location).label} +
    • +
    +
    + ); +} - New Post - -
    - {posts !== null && ( -
    - {posts.length === 0 ? ( -
    You have no posts.
    - ) : ( - posts.map( - ({ - id, - lab: { name: labName }, - title, - closingDate, - _count: { applicants: applicantCount }, - }) => ( - { + return await ( + await fetch(`/api/applications/${id}/unsave`, { + method: 'DELETE', + }) + ).json(); + }, + onSettled: (data) => { + if (data?.unsaved) { + updateJob(); + } + }, + }); + return ( +
    +
    +
    {labName}
    + {mutation.isLoading ? ( + + ) : ( + + )} +
    +
    {title}
    +
      +
    • + {Departments.find(({ value }) => value === departments[0]).label} +
    • +
    • + {locations.find(({ value }) => value === location).label} +
    • +
    +
    + ); +} + +const departments = [ + { value: 'HUMANITIES', label: 'Humanities' }, + { value: 'PHYSICAL_SCIENCES', label: 'Physical Sciences' }, + { value: 'LIFE_SCIENCES', label: 'Life Sciences' }, + { value: 'ENGINEERING', label: 'Engineering' }, + { value: 'SOCIAL_SCIENCES', label: 'Social Sciences' }, +]; +const locations = [ + { value: 'ON_CAMPUS', label: 'On Campus' }, + { value: 'OFF_CAMPUS', label: 'Off Campus' }, + { value: 'REMOTE', label: 'Remote' }, +]; +const durations = [ + { value: 'QUARTERLY', label: 'Quarterly' }, + { value: 'SUMMER', label: 'Summer' }, + { value: 'ACADEMIC_YEAR', label: 'Academic Year' }, + { value: 'YEAR_ROUND', label: 'Year Round' }, +]; +const payRanges = [ + { value: true, label: 'Paid' }, + { value: false, label: 'Unpaid' }, +]; + +function Home({ jobs: originalJobs }) { + const { isLoading, isError, data, status } = useQuery({ + queryKey: ['applications'], + queryFn: async () => { + return await (await fetch('/api/applications')).json(); + }, + refetchOnMount: false, + refetchOnReconnect: false, + refetchOnWindowFocus: false, + cacheTime: 0, // don't cache. TODO: decide on what to to for researchers: maybe don't fetch at all + }); + + const [jobs, setJobs] = useState(null); + const [selectedDepartments, setSelectedDepartments] = useState([]); + const [selectedDurations, setSelectedDurations] = useState([]); + const [selectedPayRanges, setSelectedPayRanges] = useState([]); + const [selectedLocations, setSelectedLocations] = useState([]); + const [selectedJobID, setSelectedJobID] = useState(null); + const [copiedExternalLink, setCopiedExternalLink] = useState(false); + + const [accountType, setAccountType] = useState(null); + + useEffect(() => { + const storedAccountType = localStorage.getItem('accountType'); + if (storedAccountType === 'researcher' || storedAccountType === 'student') { + setAccountType(storedAccountType); + } else { + async function getAccountType() { + try { + const accType = (await (await fetch('/api/account-type')).json()).accountType; + // localStorage.setItem('accountType', accType); // let NavBar set accountType in localStorage + setAccountType(accType); + } catch (err) { + setAccountType('student'); // default to student // TODO: improve this when fetch fails + } + } + getAccountType(); + } + }, []); + + const [isFiltersDialogOpen, setIsFiltersDialogOpen] = useState(!true); + + const [isReversed, setIsReversed] = useState(false); + useEffect(() => { + if (status !== 'success') { + return; + } + const markedJobs = data.map(({ job: { id } }) => id); + const unmarkedJobs = originalJobs + .filter(({ id }) => !markedJobs.includes(id)) + .map((j) => ({ ...j, status: null })); // .map((j) => ({ ...j, saved: false })); + setJobs(unmarkedJobs); + setSelectedJobID(unmarkedJobs[0]?.id); + }, [data, status, originalJobs]); + + const { + register, + handleSubmit, + formState: { errors }, + } = useForm({ + resolver: joiResolver(JobSearchValidator), + }); + + const appliedMutation = useMutation({ + mutationFn: async () => { + return await ( + await fetch(`/api/applications/${selectedJobID}`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + status: 'APPLIED', + }), + }) + ).json(); + }, + onSettled: (data) => { + if (data?.lastUpdated) { + setCopiedExternalLink(false); + } + }, + }); + // useEffect(() => setCopiedExternalLink(false), [selectedJobID]); + + useEffect(() => { + if (errors?.jobSearchQuery?.type === 'string.empty') { + setJobs(originalJobs); + } + }, [errors]); + + let filteredJobs = jobs ? [...jobs] : []; + if (selectedPayRanges.length !== 0 && selectedPayRanges.length !== departments.length) { + filteredJobs = filteredJobs.filter(({ paid }) => selectedPayRanges.includes(paid)); + } + if (selectedLocations.length !== 0 && selectedLocations.length !== locations.length) { + filteredJobs = filteredJobs.filter(({ location }) => selectedLocations.includes(location)); + } + if (selectedDurations.length !== 0 && selectedDurations.length !== durations.length) { + filteredJobs = filteredJobs.filter(({ duration }) => selectedDurations.includes(duration)); + } + if (selectedDepartments.length !== 0 && selectedDepartments.length !== departments.length) { + filteredJobs = filteredJobs.filter(({ departments }) => + departments.some((item) => selectedDepartments.includes(item)) + ); + } + if (isReversed) { + filteredJobs.reverse(); + } + + const selectedJob = jobs && jobs.find(({ id }) => id === selectedJobID); + + const dateFormatter = new Intl.DateTimeFormat('en-US', { + month: 'long', + day: 'numeric', + year: 'numeric', + }); + + return ( +
    + + bResearch + + +
    +
    +
    +
    +
    { + try { + const response = await fetch( + `/api/jobs/search?jobSearchQuery=${d.jobSearchQuery}` + ); + if (response.ok) { + const jobs = await response.json(); + setJobs(jobs); + } + } catch (e) {} + })} + > + + {/* TODO: put svg in button and make it submit form */} + + + - ) - ) - )} + +
    + +
    + {/* https://www.w3docs.com/snippets/css/how-to-set-absolute-positioning-relative-to-the-parent-element.html */} + + + Department + + + {departments.map(({ value, label }) => ( + + {({ active, selected }) => ( +
  • + + {label} +
  • + )} +
    + ))} +
    +
    +
    +
    + + + Duration + + + {durations.map(({ value, label }) => ( + + {({ active, selected }) => ( +
  • + + {label} +
  • + )} +
    + ))} +
    +
    +
    +
    + + + Pay + + + {payRanges.map(({ value, label }) => ( + + {({ active, selected }) => ( +
  • + + {label} +
  • + )} +
    + ))} +
    +
    +
    +
    + + + Locations + + + {locations.map(({ value, label }) => ( + + {({ active, selected }) => ( +
  • + + {label} +
  • + )} +
    + ))} +
    +
    +
    + +
    - )} + {jobs !== null && !isLoading && !isError && ( +
    +
    + {/* TODO: maybe add overflow-y-scroll and correct height above */} + {filteredJobs.length ? ( + filteredJobs.map( + ({ + id, + title, + departments, + duration, + location, + lab: { name: labName }, + status, + }) => { + if (accountType === 'researcher') { + return ( + { + setSelectedJobID(id); + setCopiedExternalLink(false); + }} + isSelectedJob={selectedJobID === id} + /> + ); + } + return status === 'SAVED' ? ( + + setJobs(jobs.map((j) => (j.id === id ? { ...j, status: null } : j))) + } + labName={labName} + title={title} + departments={departments} + duration={duration} + location={location} + setSelectedJob={() => { + setSelectedJobID(id); + setCopiedExternalLink(false); + }} + isSelectedJob={selectedJobID === id} + /> + ) : ( + + setJobs(jobs.map((j) => (j.id === id ? { ...j, status: 'SAVED' } : j))) + } + labName={labName} + title={title} + departments={departments} + duration={duration} + location={location} + setSelectedJob={() => { + setSelectedJobID(id); + setCopiedExternalLink(false); + }} + isSelectedJob={selectedJobID === id} + /> + ); + } + // id, updateJob, title, description, duration, toast, departments/> + ) + ) : ( +
    There are zero unsaved jobs that match these filters
    + )} +
    + {selectedJob && ( +
    + <> +
    {selectedJob.lab.name}
    +
    {selectedJob.title}
    +
    +
    Posted on {dateFormatter.format(new Date(selectedJob.created))}
    + +
    + {selectedJob._count.applicants} Applicant + {selectedJob._count.applicants !== 1 && 's'} +
    +
    +
    + {[ + { + field: 'Department', + value: + departments.find(({ value }) => value === selectedJob.departments[0]) + ?.label ?? '', + }, + { + field: 'Location', + value: locations.find(({ value }) => value === selectedJob.location) + .label, + }, + // { field: 'Position Type', value: '' }, + { + field: 'Desired Start Date', + value: new Intl.DateTimeFormat('en-US', { dateStyle: 'medium' }).format( + new Date(selectedJob.startDate) + ), + }, + // { field: 'Research Area', value: '' }, + { + field: 'Duration', + value: durations.find(({ value }) => value === selectedJob.duration) + .label, + }, + { + field: 'Approved for Credit', + value: selectedJob.credit === null ? 'No' : selectedJob.credit || 'Yes', + }, + ].map(({ field, value }) => ( +
    +
    {field}
    +
    {value}
    +
    + ))} +
    + {accountType === 'student' && ( +
    + {selectedJob.status === 'SAVED' ? ( + + ) : ( + + )} + {selectedJob.externalLink ? ( + <> + + {copiedExternalLink && ( +
    +
    + Copied Application{' '} + {selectedJob.externalLink.startsWith('http') ? 'Link' : 'Email'}. + Did you apply? +
    + +
    + )} + + ) : ( + <> + + + )} +
    + )} +
    +
    + Approximate hours per week:{' '} + {selectedJob.weeklyHours} +
    +
    + Applications Accepted Until:{' '} + {dateFormatter.format(new Date(selectedJob.closingDate))} +
    +
    +
    Job Description:
    +
    +
    +
    + +
    + )} +
    + )} +
    - + + setIsFiltersDialogOpen(false)}> + {/* TODO: Add font https://stackoverflow.com/questions/75422265/next-font-works-everywhere-except-one-specific-component */} + +
    + + +
    +
    + + + + All Filters + +
    +
    +
    Departments
    + {departments.map(({ value, label }) => { + const updateFilter = () => + setSelectedDepartments( + selectedDepartments.includes(value) + ? selectedDepartments.filter((d) => d !== value) + : [...selectedDepartments, value] + ); + return ( +
    + + +
    + ); + })} +
    +
    +
    Duration
    + {durations.map(({ value, label }) => { + const updateFilter = () => + setSelectedDurations( + selectedDurations.includes(value) + ? selectedDurations.filter((duration) => duration !== value) + : [...selectedDurations, value] + ); + return ( +
    + + +
    + ); + })} +
    + +
    +
    Pay
    + {payRanges.map(({ value, label }) => { + const updateFilter = () => + setSelectedPayRanges( + selectedPayRanges.includes(value) + ? selectedPayRanges.filter((p) => p !== value) + : [...selectedPayRanges, value] + ); + return ( +
    + + +
    +
    + ); + })} +
    +
    +
    Locations
    + {locations.map(({ value, label }) => { + const updateFilter = () => + setSelectedLocations( + selectedLocations.includes(value) + ? selectedLocations.filter((location) => location !== value) + : [...selectedLocations, value] + ); + return ( +
    + + +
    + ); + })} +
    +
    + +
    + +
    +
    +
    +
    +
    +
    +
    +
    ); } -export default ResearcherPosts; +export async function getStaticProps() { + const jobs = await prisma.job.findMany({ + select: { + id: true, + title: true, + description: true, + departments: true, + duration: true, + location: true, + lab: { select: { name: true } }, + created: true, + startDate: true, + closingDate: true, + credit: true, + weeklyHours: true, + paid: true, + externalLink: true, + _count: { + select: { + applicants: { where: { status: 'APPLIED' } }, + }, + }, + }, + where: { closed: false, closingDate: { gt: new Date() } }, + take: 50, + }); + + return { + props: { + jobs: jobs.map((job) => ({ + ...job, + created: JSON.parse(JSON.stringify(job.created)), + closingDate: JSON.parse(JSON.stringify(job.closingDate)), + startDate: JSON.parse(JSON.stringify(job.startDate)), + })), + }, + }; +} + +export default Home; diff --git a/pages/researcher/profile/create.jsx b/pages/researcher/profile/create.jsx index 2ae4ac9..c73e77c 100644 --- a/pages/researcher/profile/create.jsx +++ b/pages/researcher/profile/create.jsx @@ -4,7 +4,7 @@ import { useRouter } from 'next/router'; import { useForm, Controller } from 'react-hook-form'; import { joiResolver } from '@hookform/resolvers/joi'; import { ResearcherProfileCreationValidator } from '@lib/validators'; -import { Departments } from '@lib/globals'; +import { Departments, Paths } from '@lib/globals'; function OnboardingPage() { const [isSubmitting, setIsSubmitting] = useState(false); @@ -32,7 +32,7 @@ function OnboardingPage() { }, }); if (res.status === 200) { - await router.push('/posts'); + await router.push(Paths.ManagePostsPage); } } catch (e) {} setIsSubmitting(false); diff --git a/pages/student/profile/add.jsx b/pages/student/profile/add.jsx index b9fc0bb..9e93e49 100644 --- a/pages/student/profile/add.jsx +++ b/pages/student/profile/add.jsx @@ -7,6 +7,7 @@ import Head from 'next/head'; import CreatableSelect from 'react-select/creatable'; import { useEffect, useState } from 'react'; import { useDropzone } from 'react-dropzone'; +import { Paths } from '@lib/globals'; function DisabledInput({ id, value, ...props }) { if (!id || value === undefined || Object.keys(props).length) { @@ -138,256 +139,256 @@ function AddToProfile() { Add to Profile -
    - {/*
    */} -
    - {/*
    */} - {/* TODO: fix above main width */} -
    -

    Add More Details

    -
    -
    - {/*
    +
    + {/*
    */} +
    + {/*
    */} + {/* TODO: fix above main width */} +
    +

    Add More Details

    +
    + + {/*

    Personal Information

    */} -
    -
    - -
    -
    - -

    - {pdfError || - (pdf ? ( - `Successfully attached ${pdf.name}` - ) : ( - <> - Click to Upload or Drag and Drop -
    - PDF File up to 100 KB - - ))} -

    -
    - + +

    + {pdfError || + (pdf ? ( + `Successfully attached ${pdf.name}` + ) : ( + <> + Click to Upload or Drag and Drop +
    + PDF File up to 100 KB + + ))} +

    +
    + +
    -
    -
    -
    - - {/* https://stackoverflow.com/questions/50229792/adding-a-new-line-in-a-jsx-string-inside-a-paragraph-react */} - s} - // onCreateOption={(s) => { - // setSkills((sk) => { - // return [...sk, s]; - // }); - // }} - // getNewOptionData={(inputVal) => { - // console.log({ inputVal }, 'getNew'); - // }} - isValidNewOption={(s) => s.trim().length > 0} - onChange={(opt, meta) => { - // console.log({ opt, meta }); - if (meta.action === 'create-option') { - setSkills((sk) => [...sk, meta.option.value]); - } else if (meta.action === 'pop-value') { - setSkills((sk) => sk.slice(0, -1)); - } else if (meta.action === 'remove-value') { - setSkills((sk) => { - const skillsCopy = [...sk]; - const index = skillsCopy.indexOf(meta.removedValue.value); - if (index >= 0) { - skillsCopy.splice(index, 1); - } - return skillsCopy; - }); - } else if (meta.action === 'clear') { - setSkills([]); - } - }} - // max={3} - /> -
    - - {/* TODO: Use this instead of only using react-select */} - {/* {skills.map((s) => ( +
    +
    + + {/* https://stackoverflow.com/questions/50229792/adding-a-new-line-in-a-jsx-string-inside-a-paragraph-react */} + s} + // onCreateOption={(s) => { + // setSkills((sk) => { + // return [...sk, s]; + // }); + // }} + // getNewOptionData={(inputVal) => { + // console.log({ inputVal }, 'getNew'); + // }} + isValidNewOption={(s) => s.trim().length > 0} + onChange={(opt, meta) => { + // console.log({ opt, meta }); + if (meta.action === 'create-option') { + setSkills((sk) => [...sk, meta.option.value]); + } else if (meta.action === 'pop-value') { + setSkills((sk) => sk.slice(0, -1)); + } else if (meta.action === 'remove-value') { + setSkills((sk) => { + const skillsCopy = [...sk]; + const index = skillsCopy.indexOf(meta.removedValue.value); + if (index >= 0) { + skillsCopy.splice(index, 1); + } + return skillsCopy; + }); + } else if (meta.action === 'clear') { + setSkills([]); + } + }} + // max={3} + /> +
    + + {/* TODO: Use this instead of only using react-select */} + {/* {skills.map((s) => (
      {s}
    ))} */} -
    -
    -
    - - {/* https://stackoverflow.com/questions/50229792/adding-a-new-line-in-a-jsx-string-inside-a-paragraph-react */} -
    - -
    -
    -
    - - +
    +
    + + {/* https://stackoverflow.com/questions/50229792/adding-a-new-line-in-a-jsx-string-inside-a-paragraph-react */} + +
    +
    - -
    -
    -
    - - - value.startsWith('http') ? value : `https://${value}` - } - formatCreateLabel={(s) => (s.startsWith('http') ? s : `https://${s}`)} - isValidNewOption={(s) => { - if (s.trim().length === 0) { - return false; - } - let { error } = SecondProfileCreationValidator.validate({ - skills: [], - links: [s], - }); - if (error) { - ({ error } = SecondProfileCreationValidator.validate({ - skills: [], - links: [`https://${s}`], - })); +
    +
    + + +
    + +
    +
    +
    + + + value.startsWith('http') ? value : `https://${value}` } - return !error; - }} - onChange={(opt, meta) => { - // console.log({ opt, meta }); - if (meta.action === 'create-option') { + formatCreateLabel={(s) => (s.startsWith('http') ? s : `https://${s}`)} + isValidNewOption={(s) => { + if (s.trim().length === 0) { + return false; + } let { error } = SecondProfileCreationValidator.validate({ skills: [], - links: [meta.option.value], + links: [s], }); if (error) { ({ error } = SecondProfileCreationValidator.validate({ skills: [], - links: [`https://${meta.option.value}`], + links: [`https://${s}`], })); } - if (error) { - return; - } - if (meta.option.value.startsWith('http')) { - setLinks((l) => [...l, meta.option.value]); - } else { - setLinks((l) => [...l, `https://${meta.option.value}`]); - } - } else if (meta.action === 'pop-value') { - setLinks((l) => l.slice(0, -1)); - } else if (meta.action === 'remove-value') { - setLinks((l) => { - const linksCopy = [...l]; - const index = linksCopy.indexOf(meta.removedValue.value); - if (index >= 0) { - linksCopy.splice(index, 1); + return !error; + }} + onChange={(opt, meta) => { + // console.log({ opt, meta }); + if (meta.action === 'create-option') { + let { error } = SecondProfileCreationValidator.validate({ + skills: [], + links: [meta.option.value], + }); + if (error) { + ({ error } = SecondProfileCreationValidator.validate({ + skills: [], + links: [`https://${meta.option.value}`], + })); } - return linksCopy; - }); - } else if (meta.action === 'clear') { - setLinks([]); - } - }} - /> + if (error) { + return; + } + if (meta.option.value.startsWith('http')) { + setLinks((l) => [...l, meta.option.value]); + } else { + setLinks((l) => [...l, `https://${meta.option.value}`]); + } + } else if (meta.action === 'pop-value') { + setLinks((l) => l.slice(0, -1)); + } else if (meta.action === 'remove-value') { + setLinks((l) => { + const linksCopy = [...l]; + const index = linksCopy.indexOf(meta.removedValue.value); + if (index >= 0) { + linksCopy.splice(index, 1); + } + return linksCopy; + }); + } else if (meta.action === 'clear') { + setLinks([]); + } + }} + /> +
    +
    - -
    -
    - - Skip for now - - -
    - -
    -
    +
    + + Skip for now + + +
    + + +
    ); }