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 (
+
+
+ );
+}
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 (
-
-
- );
-}
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' ? (
);
};
-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 ? (
) : (
-
+
);
};
diff --git a/client/src/components/Button/style.module.scss b/client/src/components/Button/style.module.scss
index 62063f6..3d9cfd0 100644
--- a/client/src/components/Button/style.module.scss
+++ b/client/src/components/Button/style.module.scss
@@ -26,4 +26,8 @@
border: 1px solid vars.$btn-primary;
color: vars.$btn-primary;
}
+
+ &:disabled {
+ opacity: 0.5;
+ }
}
diff --git a/client/src/components/FileSelect/index.tsx b/client/src/components/FileSelect/index.tsx
new file mode 100644
index 0000000..c6f07a2
--- /dev/null
+++ b/client/src/components/FileSelect/index.tsx
@@ -0,0 +1,62 @@
+import { useId, useState } from 'react';
+import Button from '../Button';
+import styles from './style.module.scss';
+import Typography from '../Typography';
+import ErrorIcon from '../../../public/assets/icons/error.svg';
+
+interface FileSelectProps {
+ fileTypes: string[];
+ maxSize: number;
+ name: string;
+ required?: boolean;
+ disabled?: boolean;
+ defaultFile?: File;
+}
+const FileSelect = ({
+ fileTypes,
+ maxSize,
+ name,
+ required,
+ disabled,
+ defaultFile,
+}: FileSelectProps) => {
+ const id = useId();
+ const [selected, setSelected] = useState(defaultFile);
+
+ return (
+ <>
+
+ {selected?.name}
+ {
+ if (e.currentTarget.files?.[0]) {
+ setSelected(e.currentTarget.files[0]);
+ }
+ }}
+ />
+
+
+ {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"