diff --git a/client/next.config.js b/client/next.config.js index b39936d..68075bb 100644 --- a/client/next.config.js +++ b/client/next.config.js @@ -23,6 +23,18 @@ const nextConfig = { return config; }, + async redirects() { + return [ + { + source: '/apply', + destination: '/apply/1', + permanent: true, + }, + ]; + }, + experimental: { + serverActions: true, + }, }; module.exports = nextConfig; diff --git a/client/package.json b/client/package.json index 1b1bc8b..6eed6af 100644 --- a/client/package.json +++ b/client/package.json @@ -21,6 +21,7 @@ "@types/validator": "^13.12.2", "axios": "^1.7.9", "cookies-next": "^5.0.2", + "localforage": "^1.10.0", "next": "13.4.16", "react": "18.2.0", "react-dom": "18.2.0", diff --git a/client/src/app/api/login/route.ts b/client/src/app/api/login/route.ts index 30fd728..3a3c7b6 100644 --- a/client/src/app/api/login/route.ts +++ b/client/src/app/api/login/route.ts @@ -23,9 +23,9 @@ export async function POST(request: NextRequest) { await setCookie(CookieType.USER, JSON.stringify(response.user)); return response; } catch (error) { - if (error instanceof AxiosError) { - return NextResponse.json({ error: getErrorMessage(error) }, { status: error.status || 500 }); - } - return NextResponse.json({ error: error }, { status: 500 }); + return NextResponse.json( + { error: getErrorMessage(error) }, + { status: error instanceof AxiosError ? error.status || 500 : 500 } + ); } } diff --git a/client/src/app/api/updateUser/route.ts b/client/src/app/api/updateUser/route.ts index b9ce237..5e2b725 100644 --- a/client/src/app/api/updateUser/route.ts +++ b/client/src/app/api/updateUser/route.ts @@ -1,6 +1,6 @@ import config from '@/lib/config'; import type { UserPatches, PatchUserRequest } from '@/lib/types/apiRequests'; -import type { PatchUserResponse } from '@/lib/types/apiResponses'; +import type { UpdateCurrentUserReponse } from '@/lib/types/apiResponses'; import { getCookie, setCookie } from '@/lib/services/CookieService'; import { CookieType } from '@/lib/types/enums'; import { NextResponse, NextRequest } from 'next/server'; @@ -9,12 +9,12 @@ import axios, { AxiosError } from 'axios'; const updateCurrentUserProfile = async ( token: string, user: UserPatches -): Promise => { +): Promise => { const requestUrl = `${config.api.baseUrl}${config.api.endpoints.user.user}`; const requestBody: PatchUserRequest = { user }; - const response = await axios.patch(requestUrl, requestBody, { + const response = await axios.patch(requestUrl, requestBody, { headers: { Authorization: `Bearer ${token}`, }, diff --git a/client/src/app/apply/page.module.scss b/client/src/app/apply/[step]/page.module.scss similarity index 100% rename from client/src/app/apply/page.module.scss rename to client/src/app/apply/[step]/page.module.scss diff --git a/client/src/app/apply/[step]/page.tsx b/client/src/app/apply/[step]/page.tsx new file mode 100644 index 0000000..4fb7f3b --- /dev/null +++ b/client/src/app/apply/[step]/page.tsx @@ -0,0 +1,76 @@ +import ApplicationStep from '@/components/ApplicationStep'; +import { appQuestions } from '@/config'; +import styles from './page.module.scss'; +import ApplicationReview from '@/components/ApplicationReview'; +import Progress from '@/components/Progress'; +import Card from '@/components/Card'; +import Button from '@/components/Button'; +import Typography from '@/components/Typography'; +import PartyPopper from '@/../public/assets/party-popper.svg'; +import { redirect } from 'next/navigation'; +import { ResponseAPI } from '@/lib/api'; +import { getCookie } from '@/lib/services/CookieService'; +import { CookieType } from '@/lib/types/enums'; +import { AxiosError } from 'axios'; + +const STEP_REVIEW = appQuestions.length + 1; +const STEP_SUBMITTED = appQuestions.length + 2; + +type ApplicationPageProps = { + params: Promise<{ step: string }>; +}; + +export default async function ApplicationPage({ params }: ApplicationPageProps) { + const accessToken = await getCookie(CookieType.ACCESS_TOKEN); + const step = Number((await params).step); + + // For now, prohibit user from editing application + if (step < STEP_SUBMITTED) { + let exists = false; + try { + await ResponseAPI.getApplication(accessToken); + exists = true; + } catch (error) { + if (!(error instanceof AxiosError && error.status === 404)) { + console.log(error); + redirect('/login'); + } + } + if (exists) { + // If it exists, they've submitted their application + // NOTE: Cannot redirect inside try-catch + redirect(`/apply/${STEP_SUBMITTED}`); + } + } + + return ( +
+ shortName), 'Review']} + step={step - 1} + /> + {appQuestions[step - 1] ? ( + + ) : step === STEP_REVIEW ? ( + + ) : step === STEP_SUBMITTED ? ( + + + + Woohooo!! You successfully submitted your DiamondHacks application! Check back for + updates on the dashboard. + + + + ) : null} +
+ ); +} diff --git a/client/src/app/apply/page.tsx b/client/src/app/apply/page.tsx deleted file mode 100644 index a4273c7..0000000 --- a/client/src/app/apply/page.tsx +++ /dev/null @@ -1,48 +0,0 @@ -'use client'; - -import ApplicationStep from '@/components/ApplicationStep'; -import { appQuestions } from '@/config'; -import styles from './page.module.scss'; -import { useSearchParams } from 'next/navigation'; -import ApplicationReview from '@/components/ApplicationReview'; -import Progress from '@/components/Progress'; -import Card from '@/components/Card'; -import Button from '@/components/Button'; -import Typography from '@/components/Typography'; -import PartyPopper from '@/../public/assets/party-popper.svg'; - -export default function Application() { - const searchParams = useSearchParams(); - const step = Number(searchParams.get('step') ?? 1); - - return ( -
- shortName), 'Review']} - step={step - 1} - /> - {appQuestions[step - 1] ? ( - - ) : step === appQuestions.length + 1 ? ( - - ) : step === appQuestions.length + 2 ? ( - - - - Woohooo!! You successfully submitted your DiamondHacks application! Check back for - updates on the dashboard. - - - - ) : null} -
- ); -} diff --git a/client/src/app/login/page.tsx b/client/src/app/login/page.tsx index 5583df9..d124408 100644 --- a/client/src/app/login/page.tsx +++ b/client/src/app/login/page.tsx @@ -27,12 +27,7 @@ export default function LoginPage() { register, handleSubmit, formState: { errors }, - } = useForm({ - defaultValues: { - email: '', - password: '', - }, - }); + } = useForm(); const onSubmit: SubmitHandler = async credentials => { try { diff --git a/client/src/components/ApplicationReview/index.tsx b/client/src/components/ApplicationReview/index.tsx index 5ec56de..d31b489 100644 --- a/client/src/components/ApplicationReview/index.tsx +++ b/client/src/components/ApplicationReview/index.tsx @@ -1,20 +1,100 @@ +'use client'; + import Card from '../Card'; import Link from 'next/link'; import Heading from '../Heading'; import styles from './style.module.scss'; import Button from '../Button'; import { appQuestions } from '@/config'; -import { Fragment } from 'react'; +import { Fragment, useEffect, useState } from 'react'; +import { useRouter } from 'next/navigation'; +import { ResponseAPI } from '@/lib/api'; +import { Yes, YesOrNo } from '@/lib/types/enums'; +import showToast from '@/lib/showToast'; +import { Application } from '@/lib/types/application'; +import localforage from 'localforage'; +import { SavedResponses, SAVED_RESPONSES_KEY } from '../ApplicationStep'; interface ApplicationReviewProps { - responses: Record; + accessToken: string; + responses: Record; + responsesLoaded?: boolean; prev: string; next: string; } -const ApplicationReview = ({ responses, prev, next }: ApplicationReviewProps) => { +const ApplicationReview = ({ + accessToken, + responses, + responsesLoaded = true, + prev, + next, +}: ApplicationReviewProps) => { + const router = useRouter(); + return ( - + { + e.preventDefault(); + + if ( + !confirm( + 'Are you sure you want to submit? You currently will not be able to edit your application after submitting.' + ) + ) { + return; + } + + if (responses.mlhCodeOfConduct !== 'Yes') { + showToast('Please agree to the MLH code of conduct.'); + return; + } + if (responses.mlhAuthorization !== 'Yes') { + showToast('Please agree to the MLH terms and conditions.'); + return; + } + + const application: Application = { + phoneNumber: responses.phoneNumber, + age: responses.age, + university: responses.university, + levelOfStudy: responses.levelOfStudy, + country: responses.country, + linkedin: responses.linkedin, + gender: responses.gender, + pronouns: responses.pronouns, + orientation: responses.orientation, + ethnicity: responses.ethnicity, + dietary: responses.dietary, + interests: responses.interests, + major: responses.major, + referrer: responses.referrer, + resumeLink: 'This will be populated by the backend', + willAttend: responses.willAttend === 'Yes' ? YesOrNo.YES : YesOrNo.NO, + mlhCodeOfConduct: Yes.YES, + mlhAuthorization: Yes.YES, + mlhEmailAuthorization: + responses.mlhEmailAuthorization === 'Yes' ? YesOrNo.YES : YesOrNo.NO, + additionalComments: responses.additionalComments, + }; + // Files can't be passed to a server action but FormData can + const formData = new FormData(); + formData.append('application', JSON.stringify(application)); + formData.append('file', responses.resumeLink); + + const result = await ResponseAPI.submitApplication(accessToken, formData); + if ('error' in result) { + showToast("Couldn't submit application", result.error); + return; + } + + // Remove saved data + await localforage.removeItem(SAVED_RESPONSES_KEY); + + router.push(next); + }} + > < Back Application Review @@ -24,7 +104,31 @@ const ApplicationReview = ({ responses, prev, next }: ApplicationReviewProps) => .map(({ id, question }) => (
{question}
-
{responses[id] ?? 'No response.'}
+
+ {typeof responses[id] === 'string' ? ( + responses[id] + ) : Array.isArray(responses[id]) ? ( + responses[id].join(', ') + ) : responses[id] instanceof File ? ( + + ) : ( + No response. + )} +
))} @@ -33,10 +137,32 @@ const ApplicationReview = ({ responses, prev, next }: ApplicationReviewProps) => - +
); }; -export default ApplicationReview; +const ApplicationReviewWrapped = ( + props: Omit +) => { + const [responses, setResponses] = useState>({}); + const [responsesLoaded, setResponsesLoaded] = useState(false); + + useEffect(() => { + localforage + .getItem(SAVED_RESPONSES_KEY) + .then(responses => { + if (responses) { + setResponses(responses); + } + }) + .finally(() => setResponsesLoaded(true)); + }, []); + + return ; +}; + +export default ApplicationReviewWrapped; diff --git a/client/src/components/ApplicationStep/index.tsx b/client/src/components/ApplicationStep/index.tsx index fc3da95..694073d 100644 --- a/client/src/components/ApplicationStep/index.tsx +++ b/client/src/components/ApplicationStep/index.tsx @@ -1,4 +1,6 @@ -import { ReactNode, useId, useRef } from 'react'; +'use client'; + +import { ReactNode, useEffect, useId, useRef, useState } from 'react'; import Card from '../Card'; import Heading from '../Heading'; import Typography from '../Typography'; @@ -9,6 +11,13 @@ import { useRouter } from 'next/navigation'; import Link from 'next/link'; import ErrorIcon from '../../../public/assets/icons/error.svg'; import MultipleChoiceGroup, { OTHER } from '../MultipleChoiceGroup'; +import localforage from 'localforage'; +import showToast from '@/lib/showToast'; +import { reportError } from '@/lib/utils'; +import FileSelect from '../FileSelect'; + +export type SavedResponses = Record; +export const SAVED_RESPONSES_KEY = 'saved application'; type AppQuestion = { id: string; @@ -34,7 +43,8 @@ type AppQuestion = { } | { type: 'file'; - fileTypes: string; + fileTypes: string[]; + maxSize: number; } ); @@ -59,12 +69,16 @@ const ASTERISK = ( interface ApplicationStepProps { step: Step; + responses: Record; + responsesLoaded?: boolean; prev: string; next: string; } const ApplicationStep = ({ step: { title, description, questions }, + responses, + responsesLoaded = true, prev, next, }: ApplicationStepProps) => { @@ -77,32 +91,43 @@ const ApplicationStep = ({ return; } - // TODO: Save changes const data = new FormData(formRef.current); - console.log( - Object.fromEntries( - questions.map(question => { - if (question.type === 'select-multiple') { - return [ - question.id, - data - .getAll(question.id) - .map(response => - response === OTHER ? `[Other] ${data.get(`${question.id}-${OTHER}`)}` : response - ), - ]; - } - const response = data.get(question.id); - if (question.type === 'select-one') { - return [ - question.id, - response === OTHER ? `[Other] ${data.get(`${question.id}-${OTHER}`)}` : response, - ]; - } - return [question.id, response]; - }) - ) + const responses = Object.fromEntries( + questions.map(question => { + if (question.type === 'select-multiple') { + return [ + question.id, + data + .getAll(question.id) + .map(response => + response === OTHER ? data.get(`${question.id}-${OTHER}`) : response + ), + ]; + } + const response = data.get(question.id); + if (question.type === 'select-one') { + return [question.id, response === OTHER ? data.get(`${question.id}-${OTHER}`) : response]; + } + return [question.id, response]; + }) ); + for (const [key, value] of Object.entries(responses)) { + if (value instanceof File && value.size === 0) { + delete responses[key]; + } + } + try { + await localforage.setItem(SAVED_RESPONSES_KEY, { + ...(await localforage.getItem(SAVED_RESPONSES_KEY)), + ...responses, + }); + showToast( + 'Responses saved locally!', + 'Your responses will be lost when you clear site data. Your browser may do this automatically after a few days. Your draft will not be accessible from other devices.' + ); + } catch (error) { + reportError("Couldn't save your responses. Try a different browser or device.", error); + } } return ( @@ -142,6 +167,8 @@ const ApplicationStep = ({ inline={question.inline} other={question.other} required={!question.optional} + defaultValue={responses[question.id]} + disabled={!responsesLoaded} /> Required. @@ -170,6 +197,8 @@ const ApplicationStep = ({ placeholder="Type answer here..." required={!question.optional} className={styles.textbox} + defaultValue={responses[question.id] ?? ''} + disabled={!responsesLoaded} /> ) : question.type === 'dropdown' ? ( - Required. @@ -239,11 +265,41 @@ const ApplicationStep = ({ - +
); }; -export default ApplicationStep; +const ApplicationStepWrapped = ( + props: Omit +) => { + const [responses, setResponses] = useState>({}); + const [responsesLoaded, setResponsesLoaded] = useState(false); + + useEffect(() => { + localforage + .getItem(SAVED_RESPONSES_KEY) + .then(responses => { + if (responses) { + console.log(responses); + setResponses(responses); + } + }) + .finally(() => setResponsesLoaded(true)); + }, []); + + return ( + + ); +}; + +export default ApplicationStepWrapped; diff --git a/client/src/components/ApplicationStep/style.module.scss b/client/src/components/ApplicationStep/style.module.scss index fb01681..ef81694 100644 --- a/client/src/components/ApplicationStep/style.module.scss +++ b/client/src/components/ApplicationStep/style.module.scss @@ -49,8 +49,9 @@ } } - .uploadBtn { - align-self: flex-start; + ::placeholder { + color: inherit; + opacity: 0.3; } } diff --git a/client/src/components/Button/index.tsx b/client/src/components/Button/index.tsx index efd3c5b..5855ffa 100644 --- a/client/src/components/Button/index.tsx +++ b/client/src/components/Button/index.tsx @@ -13,6 +13,7 @@ interface ButtonProps { href?: string; for?: string; submit?: boolean; + disabled?: boolean; onClick?: () => void; className?: string; } @@ -22,6 +23,7 @@ const Button = ({ href, for: htmlFor, submit = false, + disabled = false, onClick, className = '', children, @@ -37,7 +39,7 @@ const Button = ({ ) : href ? ( ) : ( - + + {selected && selected.size > maxSize ? ( + + Your file is too large. + + ) : null} + {selected && fileTypes.every(extension => !selected.name.endsWith(extension)) ? ( + + Your file must be one of: {fileTypes.join(', ')} + + ) : null} + + ); +}; + +export default FileSelect; diff --git a/client/src/components/FileSelect/style.module.scss b/client/src/components/FileSelect/style.module.scss new file mode 100644 index 0000000..eea104a --- /dev/null +++ b/client/src/components/FileSelect/style.module.scss @@ -0,0 +1,15 @@ +@use '@/styles/vars.scss' as vars; + +.wrapper { + display: flex; + align-items: center; + gap: 1rem; + flex-wrap: wrap; +} + +.error { + display: flex; + align-items: center; + gap: 0.5rem; + color: vars.$error; +} diff --git a/client/src/components/MultipleChoiceGroup/index.tsx b/client/src/components/MultipleChoiceGroup/index.tsx index 9c85cac..3214aed 100644 --- a/client/src/components/MultipleChoiceGroup/index.tsx +++ b/client/src/components/MultipleChoiceGroup/index.tsx @@ -3,10 +3,13 @@ import { Checkbox, Radio } from '@mui/material'; import styles from './style.module.scss'; import { useRef, useState } from 'react'; -import Typography from '../Typography'; -import ErrorIcon from '../../../public/assets/icons/error.svg'; export const OTHER = '__OTHER__'; +/** + * Indicates that none of the items are checked. Used to enforce at least one + * checkbox checked if a checkbox question is required. + */ +const NONE = '__NONE__'; interface MultipleChoiceGroupProps { mode: 'radio' | 'checkbox'; @@ -15,19 +18,36 @@ interface MultipleChoiceGroupProps { other?: boolean; inline?: boolean; required?: boolean; + defaultValue?: string | string[]; + disabled?: boolean; } const MultipleChoiceGroup = ({ mode, name, choices, - inline, - other, - required, + inline = false, + other = false, + required = false, + defaultValue, + disabled = false, }: MultipleChoiceGroupProps) => { const Component = mode === 'radio' ? Radio : Checkbox; - const [selected, setSelected] = useState(''); - const [showOther, setShowOther] = useState(false); + const defaultOther = + defaultValue === undefined + ? null + : Array.isArray(defaultValue) + ? (defaultValue.filter(choice => !choices.includes(choice))[0] ?? null) + : choices.includes(defaultValue) + ? null + : defaultValue; + + const [selected, setSelected] = useState( + defaultOther !== null + ? OTHER + : ((Array.isArray(defaultValue) ? defaultValue[0] : defaultValue) ?? NONE) + ); + const [showOther, setShowOther] = useState(defaultOther !== null); const ref = useRef(null); const isOtherEnabled = mode === 'radio' ? selected === OTHER : showOther; @@ -44,15 +64,21 @@ const MultipleChoiceGroup = ({ // For checkboxes, we can require at least one checkbox by // only setting all of them `required` if none of them are // checked - required={required && (mode === 'radio' || selected === '')} + required={required && (mode === 'radio' || selected === NONE)} checked={mode === 'radio' ? selected === choice : undefined} + defaultChecked={ + defaultValue === undefined || mode === 'radio' + ? undefined + : Array.isArray(defaultValue) && defaultValue.includes(choice) + } onChange={e => { if (e.currentTarget.checked) { setSelected(choice); } else if (mode === 'checkbox' && !ref.current?.querySelector(':checked')) { - setSelected(''); + setSelected(NONE); } }} + disabled={disabled} /> {choice} @@ -71,16 +97,20 @@ const MultipleChoiceGroup = ({ { setShowOther(e.currentTarget.checked); if (e.currentTarget.checked) { setSelected(OTHER); } else if (mode === 'checkbox' && !ref.current?.querySelector(':checked')) { - setSelected(''); + setSelected(NONE); } }} + disabled={disabled} /> Other:  @@ -89,7 +119,9 @@ const MultipleChoiceGroup = ({ name={`${name}-${OTHER}`} aria-label="Other" className={styles.other} - disabled={!isOtherEnabled} + defaultValue={defaultValue === undefined ? undefined : (defaultOther ?? '')} + required={isOtherEnabled} + disabled={!isOtherEnabled || disabled} />

) : null} diff --git a/client/src/components/Progress/style.module.scss b/client/src/components/Progress/style.module.scss index 296cfa6..426986f 100644 --- a/client/src/components/Progress/style.module.scss +++ b/client/src/components/Progress/style.module.scss @@ -21,6 +21,7 @@ flex-direction: column; align-items: center; gap: 0.5rem; + max-width: 16rem; .ball { margin-top: -0.75rem; diff --git a/client/src/config.tsx b/client/src/config.tsx index 1fb2acd..81034f4 100644 --- a/client/src/config.tsx +++ b/client/src/config.tsx @@ -69,9 +69,9 @@ export const appQuestions: Step[] = [ questions: [ { type: 'phone', - id: 'phone', - question: <>Phone, - placeholder: '(858) 534-2230', + id: 'phoneNumber', + question: <>Phone (must include country code, e.g. +1 (858) 534-2230), + placeholder: '+1 (858) 534-2230', }, { type: 'dropdown', @@ -79,16 +79,16 @@ export const appQuestions: Step[] = [ question: <>Age, choices: ['18', '19', '20', '21', '22', '23', '24', '25', '25+'], }, + // { + // type: 'select-one', + // id: 'grad', + // question: <>What is your expected graduation date?, + // choices: ['2025', '2026', '2027', '2028'], + // other: true, + // }, { type: 'select-one', - id: 'grad', - question: <>What is your expected graduation date?, - choices: ['2025', '2026', '2027', '2028'], - other: true, - }, - { - type: 'select-one', - id: 'level', + id: 'levelOfStudy', question: <>Level of Study, choices: [ 'Less than Secondary / High School', @@ -133,30 +133,28 @@ export const appQuestions: Step[] = [ choices: countries, }, { - type: 'select-multiple', + type: 'select-one', id: 'gender', question: <>What is your gender?, choices: ['Male', 'Female', 'Non-binary', 'Other', 'Prefer not to say'], }, { - type: 'select-multiple', + type: 'select-one', id: 'pronouns', question: <>Pronouns, choices: ['She/Her', 'He/Him', 'They/Them', 'She/They', 'He/They', 'Prefer Not to Answer'], other: true, - optional: true, }, { type: 'select-multiple', - id: 'sexuality', + id: 'orientation', question: <>Do you consider yourself to be any of the following?, choices: ['Heterosexual or straight', 'Gay or lesbian', 'Bisexual', 'Prefer Not to Answer'], other: true, - optional: true, }, { type: 'select-multiple', - id: 'race', + id: 'ethnicity', question: <>Race/Ethnicity, choices: [ 'Asian Indian', @@ -192,13 +190,14 @@ export const appQuestions: Step[] = [ }, { type: 'file', - id: 'resume', - question: <>Upload your resume in PDF format below (Max: 100MB)., - fileTypes: '.pdf,.doc,.docx', + id: 'resumeLink', + question: <>Upload your resume below (max: 2 MB; accepted formats: .pdf, .doc, .docx)., + fileTypes: ['.pdf', '.doc', '.docx'], + maxSize: 2 * 1000 * 1000, }, { type: 'select-multiple', - id: 'topics', + id: 'interests', question: <>Which topics are you most interested in?, choices: [ 'Software Engineering', @@ -211,7 +210,7 @@ export const appQuestions: Step[] = [ }, { type: 'select-multiple', - id: 'how', + id: 'referrer', question: <>How did you hear about DiamondHacks?, choices: [ 'Instagram', @@ -233,7 +232,7 @@ export const appQuestions: Step[] = [ }, { type: 'select-one', - id: 'will-attend', + id: 'willAttend', question: ( <> If accepted, I am attending DiamondHacks on April 5-6 at UC San Diego. @@ -247,7 +246,7 @@ export const appQuestions: Step[] = [ }, { type: 'select-one', - id: 'coc', + id: 'mlhCodeOfConduct', question: ( <> I have read and agree to the{' '} @@ -264,7 +263,7 @@ export const appQuestions: Step[] = [ }, { type: 'select-one', - id: 'share-auth', + id: 'mlhAuthorization', question: ( <> I authorize you to share my application/registration information with Major League @@ -296,7 +295,7 @@ export const appQuestions: Step[] = [ }, { type: 'select-one', - id: 'spam', + id: 'mlhEmailAuthorization', question: ( <> I authorize MLH to send me occasional emails about relevant events, career @@ -304,9 +303,13 @@ export const appQuestions: Step[] = [ ), choices: ['Yes', 'No'], + }, + { + type: 'text', + id: 'additionalComments', + question: 'Anything else we should know?', optional: true, }, - { type: 'text', id: 'etc', question: 'Anything else we should know?', optional: true }, ], }, ]; diff --git a/client/src/lib/api/AuthAPI.ts b/client/src/lib/api/AuthAPI.ts index 332e56c..f63e171 100644 --- a/client/src/lib/api/AuthAPI.ts +++ b/client/src/lib/api/AuthAPI.ts @@ -1,6 +1,6 @@ import config from '@/lib/config'; import type { UserRegistration } from '@/lib/types/apiRequests'; -import type { PrivateProfile, RegistrationResponse } from '@/lib/types/apiResponses'; +import type { PrivateProfile, CreateUserResponse } from '@/lib/types/apiResponses'; import axios from 'axios'; /** @@ -11,7 +11,7 @@ import axios from 'axios'; export const register = async (user: UserRegistration): Promise => { const requestUrl = `${config.api.baseUrl}${config.api.endpoints.auth.register}`; - const response = await axios.post(requestUrl, { user: user }); + const response = await axios.post(requestUrl, { user: user }); return response.data.user; }; diff --git a/client/src/lib/api/ResponseAPI.ts b/client/src/lib/api/ResponseAPI.ts new file mode 100644 index 0000000..9882e7d --- /dev/null +++ b/client/src/lib/api/ResponseAPI.ts @@ -0,0 +1,104 @@ +'use server'; + +import config from '@/lib/config'; +import type {} from '@/lib/types/apiRequests'; +import type { + GetFormsResponse, + ResponseModel, + SubmitApplicationResponse, +} from '@/lib/types/apiResponses'; +import axios from 'axios'; +import { Application } from '../types/application'; +import { getErrorMessage } from '../utils'; + +/** + * Get current user's responses + * @returns User's responses + */ +export const getResponsesForCurrentUser = async (token: string): Promise => { + const response = await axios.get( + `${config.api.baseUrl}${config.api.endpoints.response.response}`, + { + headers: { + Authorization: `Bearer ${token}`, + }, + } + ); + return response.data.responses; +}; + +/** + * Get current user's application + * @returns User's application + */ +export const getApplication = async (token: string): Promise => { + const response = await axios.get( + `${config.api.baseUrl}${config.api.endpoints.response.application}`, + { + headers: { + Authorization: `Bearer ${token}`, + }, + } + ); + return response.data.response; +}; + +/** + * Create an application for the current user + * @param application Application + * @returns Created application + */ +export const submitApplication = async ( + token: string, + formData: FormData +): Promise => { + const requestUrl = `${config.api.baseUrl}${config.api.endpoints.response.application}`; + + try { + const response = await axios.post(requestUrl, formData, { + headers: { + Authorization: `Bearer ${token}`, + }, + }); + return response.data.response; + } catch (error) { + return { error: getErrorMessage(error) }; + } +}; + +/** + * Update current user's application + * @param application Application changes + * @returns Updated application + */ +export const updateApplication = async ( + token: string, + application: Application, + file?: File +): Promise => { + const requestUrl = `${config.api.baseUrl}${config.api.endpoints.response.application}`; + + const formData = new FormData(); + formData.append('application', JSON.stringify(application)); + if (file) { + formData.append('file', file); + } + + const response = await axios.patch(requestUrl, formData, { + headers: { + Authorization: `Bearer ${token}`, + }, + }); + return response.data.response; +}; + +/** + * Delete current user's application + */ +export const deleteApplication = async (token: string): Promise => { + await axios.delete(`${config.api.baseUrl}${config.api.endpoints.response.application}`, { + headers: { + Authorization: `Bearer ${token}`, + }, + }); +}; diff --git a/client/src/lib/api/UserAPI.ts b/client/src/lib/api/UserAPI.ts index 5eca9f9..7170000 100644 --- a/client/src/lib/api/UserAPI.ts +++ b/client/src/lib/api/UserAPI.ts @@ -2,8 +2,8 @@ import type { UserPatches, PatchUserRequest, LoginRequest } from '@/lib/types/ap import type { PrivateProfile, GetCurrentUserResponse, - PatchUserResponse, LoginResponse, + UpdateCurrentUserReponse, } from '@/lib/types/apiResponses'; import axios from 'axios'; @@ -36,6 +36,6 @@ export const getCurrentUser = async (token: string): Promise => */ export const updateCurrentUserProfile = async (user: UserPatches): Promise => { const requestBody: PatchUserRequest = { user }; - const response = await axios.patch('/api/updateUser', requestBody); + const response = await axios.patch('/api/updateUser', requestBody); return response.data.user; }; diff --git a/client/src/lib/api/index.ts b/client/src/lib/api/index.ts index 80cdcf5..5e2ce52 100644 --- a/client/src/lib/api/index.ts +++ b/client/src/lib/api/index.ts @@ -1,2 +1,3 @@ export * as AuthAPI from './AuthAPI'; +export * as ResponseAPI from './ResponseAPI'; export * as UserAPI from './UserAPI'; diff --git a/client/src/lib/config.ts b/client/src/lib/config.ts index 65e7aa9..46dd69a 100644 --- a/client/src/lib/config.ts +++ b/client/src/lib/config.ts @@ -9,6 +9,10 @@ const config = { user: { user: '/user', }, + response: { + response: '/response', + application: '/response/application', + }, }, }, }; diff --git a/client/src/lib/types/apiResponses.ts b/client/src/lib/types/apiResponses.ts index b05d5b3..b390841 100644 --- a/client/src/lib/types/apiResponses.ts +++ b/client/src/lib/types/apiResponses.ts @@ -1,7 +1,16 @@ -import { ApplicationStatus, UserAccessType } from './enums'; +import { Application } from './application'; +import { ApplicationStatus, FormType, UserAccessType } from './enums'; -// User +export interface ResponseModel { + uuid: string; + user: PrivateProfile; + createdAt: string; + updatedAt: string; + formType: FormType; + data: Application; +} +// User responses export interface PublicProfile { id: string; firstName: string; @@ -14,23 +23,48 @@ export interface PrivateProfile extends PublicProfile { applicationStatus: ApplicationStatus; createdAt: Date; updatedAt: Date; + responses?: ResponseModel; } -export interface GetCurrentUserResponse extends ApiResponse { - user: PrivateProfile; +export interface ValidatorError { + children: ValidatorError[]; + constraints: object; + property: string; + target: object; +} + +export interface CustomErrorBody { + name: string; + message: string; + httpCode: number; + stack?: string; + errors?: ValidatorError[]; +} + +export interface ApiResponse { + error: CustomErrorBody | null; } -export interface PatchUserResponse extends ApiResponse { +export interface CreateUserResponse extends ApiResponse { user: PrivateProfile; } -// Auth +export interface GetUserResponse extends ApiResponse { + user: PublicProfile; +} -export interface ApiResponse { - error: any; +export interface GetCurrentUserResponse extends ApiResponse { + user: PrivateProfile; } -export interface RegistrationResponse extends ApiResponse { +export interface UpdateCurrentUserReponse extends ApiResponse { + user: PrivateProfile; +} + +export interface DeleteCurrentUserResponse extends ApiResponse {} + +export interface UserAndToken { + token: string; user: PrivateProfile; } @@ -39,19 +73,28 @@ export interface LoginResponse extends ApiResponse { user: PrivateProfile; } -// Response types +// Firebase Responses +export interface GetIdTokenResponse { + idToken: string; + refreshToken: string; + expiresIn: string; +} -export interface ValidatorError { - children: ValidatorError[]; - constraints: object; - property: string; - target: object; +export interface SendEmailVerificationResponse { + email: string; } -export interface CustomErrorBody { - name: string; - message: string; - httpCode: number; - stack?: string; - errors?: ValidatorError[]; +// Form response responses +export interface GetFormsResponse extends ApiResponse { + responses: ResponseModel[]; } + +export interface GetFormResponse extends ApiResponse { + response: ResponseModel; +} + +export interface SubmitApplicationResponse extends ApiResponse { + response: ResponseModel; +} + +export interface DeleteApplicationResponse extends ApiResponse {} diff --git a/client/src/lib/types/application.ts b/client/src/lib/types/application.ts new file mode 100644 index 0000000..d261b20 --- /dev/null +++ b/client/src/lib/types/application.ts @@ -0,0 +1,32 @@ +import { YesOrNo, Yes } from './enums'; + +export interface Application { + phoneNumber: string; + age: string; + university: string; + levelOfStudy: string; + country: string; + linkedin: string; + gender: string; + pronouns: string; + orientation: string[]; + ethnicity: string[]; + dietary: string[]; + interests: string[]; + major: string; + referrer: string[]; + resumeLink: string; + willAttend: YesOrNo; + mlhCodeOfConduct: Yes; + mlhAuthorization: Yes; + mlhEmailAuthorization: YesOrNo; + additionalComments: string; +} + +export interface CreateApplicationRequest { + application: Application; +} + +export interface UpdateApplicationRequest { + application: Application; +} diff --git a/client/src/lib/types/enums.ts b/client/src/lib/types/enums.ts index ec8f21f..ef17161 100644 --- a/client/src/lib/types/enums.ts +++ b/client/src/lib/types/enums.ts @@ -11,7 +11,23 @@ export enum ApplicationStatus { WITHDRAWN = 'WITHDRAWN', ACCEPTED = 'ACCEPTED', REJECTED = 'REJECTED', - ALL_DONE = 'ALL_DONE', + CONFIRMED = 'CONFIRMED', +} + +export enum FormType { + APPLICATION = 'APPLICATION', +} + +export enum YesOrNo { + YES = 'YES', + NO = 'NO', +} + +export enum Yes { + YES = 'YES', +} +export enum MediaType { + RESUME = 'RESUME', } export enum CookieType { diff --git a/client/src/lib/utils.ts b/client/src/lib/utils.ts index 657aea2..e9a2383 100644 --- a/client/src/lib/utils.ts +++ b/client/src/lib/utils.ts @@ -30,7 +30,7 @@ export const getMessagesFromError = (errBody: CustomErrorBody): string[] => { export function getErrorMessage(error: unknown): string { if (error instanceof AxiosError && error.response?.data?.error) { const response: ApiResponse = error.response.data; - return getMessagesFromError(response.error).join('\n\n') || error.message; + return (response.error && getMessagesFromError(response.error).join('\n\n')) || error.message; } if (error instanceof Error) { return error.message; diff --git a/client/src/middleware.ts b/client/src/middleware.ts index 34b1aaf..16b79ea 100644 --- a/client/src/middleware.ts +++ b/client/src/middleware.ts @@ -12,5 +12,5 @@ export function middleware(request: NextRequest) { } export const config = { - matcher: ['/', '/profile'], + matcher: ['/', '/profile', '/apply/:step'], }; diff --git a/client/yarn.lock b/client/yarn.lock index d4bed3d..7f46929 100644 --- a/client/yarn.lock +++ b/client/yarn.lock @@ -2846,6 +2846,11 @@ ignore@^5.2.0: resolved "https://registry.yarnpkg.com/ignore/-/ignore-5.3.2.tgz#3cd40e729f3643fd87cb04e50bf0eb722bc596f5" integrity sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g== +immediate@~3.0.5: + version "3.0.6" + resolved "https://registry.yarnpkg.com/immediate/-/immediate-3.0.6.tgz#9db1dbd0faf8de6fbe0f5dd5e56bb606280de69b" + integrity sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ== + immutable@^5.0.2: version "5.0.3" resolved "https://registry.yarnpkg.com/immutable/-/immutable-5.0.3.tgz#aa037e2313ea7b5d400cd9298fa14e404c933db1" @@ -3186,11 +3191,25 @@ levn@^0.4.1: prelude-ls "^1.2.1" type-check "~0.4.0" +lie@3.1.1: + version "3.1.1" + resolved "https://registry.yarnpkg.com/lie/-/lie-3.1.1.tgz#9a436b2cc7746ca59de7a41fa469b3efb76bd87e" + integrity sha512-RiNhHysUjhrDQntfYSfY4MU24coXXdEOgw9WGcKHNeEwffDYbF//u87M1EWaMGzuFoSbqW0C9C6lEEhDOAswfw== + dependencies: + immediate "~3.0.5" + lines-and-columns@^1.1.6: version "1.2.4" resolved "https://registry.yarnpkg.com/lines-and-columns/-/lines-and-columns-1.2.4.tgz#eca284f75d2965079309dc0ad9255abb2ebc1632" integrity sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg== +localforage@^1.10.0: + version "1.10.0" + resolved "https://registry.yarnpkg.com/localforage/-/localforage-1.10.0.tgz#5c465dc5f62b2807c3a84c0c6a1b1b3212781dd4" + integrity sha512-14/H1aX7hzBBmmh7sGPd+AOMkkIrHM3Z1PAyGgZigA1H1p5O5ANnMyWzvpAETtG68/dC4pC0ncy3+PPGzXZHPg== + dependencies: + lie "3.1.1" + locate-path@^6.0.0: version "6.0.0" resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-6.0.0.tgz#55321eb309febbc59c4801d931a72452a681d286"