Skip to content

Commit

Permalink
Frontend - Hookup application (#57)
Browse files Browse the repository at this point in the history
* allow autofill password, report login error

* Switch away from url parameter

* Set up ResponseAPI

Turns out the backend does a lot of strict validation oof

* Submit application

* Confirm submit

* fix type errors, fix question.id

* Save response locally then use it to submit application

* Show currently selected file

* Fix some form validation bugs

* allow file select to wrap

* Enforce file extension and max size for file input, remove TODO comment
  • Loading branch information
SheepTester authored Jan 10, 2025
1 parent 40d98c2 commit e61b34f
Show file tree
Hide file tree
Showing 29 changed files with 736 additions and 179 deletions.
12 changes: 12 additions & 0 deletions client/next.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,18 @@ const nextConfig = {

return config;
},
async redirects() {
return [
{
source: '/apply',
destination: '/apply/1',
permanent: true,
},
];
},
experimental: {
serverActions: true,
},
};

module.exports = nextConfig;
1 change: 1 addition & 0 deletions client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
8 changes: 4 additions & 4 deletions client/src/app/api/login/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
);
}
}
6 changes: 3 additions & 3 deletions client/src/app/api/updateUser/route.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -9,12 +9,12 @@ import axios, { AxiosError } from 'axios';
const updateCurrentUserProfile = async (
token: string,
user: UserPatches
): Promise<PatchUserResponse> => {
): Promise<UpdateCurrentUserReponse> => {
const requestUrl = `${config.api.baseUrl}${config.api.endpoints.user.user}`;

const requestBody: PatchUserRequest = { user };

const response = await axios.patch<PatchUserResponse>(requestUrl, requestBody, {
const response = await axios.patch<UpdateCurrentUserReponse>(requestUrl, requestBody, {
headers: {
Authorization: `Bearer ${token}`,
},
Expand Down
File renamed without changes.
76 changes: 76 additions & 0 deletions client/src/app/apply/[step]/page.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<main className={styles.main}>
<Progress
steps={[...appQuestions.map(({ shortName }) => shortName), 'Review']}
step={step - 1}
/>
{appQuestions[step - 1] ? (
<ApplicationStep
step={appQuestions[step - 1]}
prev={step === 1 ? '/' : `/apply/${step - 1}`}
next={`/apply/${step + 1}`}
/>
) : step === STEP_REVIEW ? (
<ApplicationReview
accessToken={accessToken}
prev={`/apply/${appQuestions.length}`}
next={`/apply/${STEP_SUBMITTED}`}
/>
) : step === STEP_SUBMITTED ? (
<Card gap={2.5} className={styles.submitted}>
<PartyPopper />
<Typography variant="headline/heavy/large">
Woohooo!! You successfully submitted your DiamondHacks application! Check back for
updates on the dashboard.
</Typography>
<Button href="/">Return to Dashboard</Button>
</Card>
) : null}
</main>
);
}
48 changes: 0 additions & 48 deletions client/src/app/apply/page.tsx

This file was deleted.

7 changes: 1 addition & 6 deletions client/src/app/login/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,12 +27,7 @@ export default function LoginPage() {
register,
handleSubmit,
formState: { errors },
} = useForm<LoginValues>({
defaultValues: {
email: '',
password: '',
},
});
} = useForm<LoginValues>();

const onSubmit: SubmitHandler<LoginValues> = async credentials => {
try {
Expand Down
140 changes: 133 additions & 7 deletions client/src/components/ApplicationReview/index.tsx
Original file line number Diff line number Diff line change
@@ -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<string, string>;
accessToken: string;
responses: Record<string, string | string[] | File | any>;
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 (
<Card gap={2}>
<Card
gap={2}
onSubmit={async e => {
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);
}}
>
<Link href={prev}>&lt; Back</Link>
<Heading>Application Review</Heading>

Expand All @@ -24,7 +104,31 @@ const ApplicationReview = ({ responses, prev, next }: ApplicationReviewProps) =>
.map(({ id, question }) => (
<Fragment key={id}>
<dt className={styles.question}>{question}</dt>
<dd className={styles.response}>{responses[id] ?? 'No response.'}</dd>
<dd className={styles.response}>
{typeof responses[id] === 'string' ? (
responses[id]
) : Array.isArray(responses[id]) ? (
responses[id].join(', ')
) : responses[id] instanceof File ? (
<Button
variant="secondary"
onClick={() => {
const link = document.createElement('a');
const url = URL.createObjectURL(responses[id]);
link.href = url;
link.download = responses[id].name;
document.body.append(link);
link.click();
link.remove();
URL.revokeObjectURL(url);
}}
>
{responses[id].name}
</Button>
) : (
<em>No response.</em>
)}
</dd>
</Fragment>
))}
</dl>
Expand All @@ -33,10 +137,32 @@ const ApplicationReview = ({ responses, prev, next }: ApplicationReviewProps) =>
<Button href="/apply" variant="secondary">
Make Changes
</Button>
<Button href={next}>Submit</Button>
<Button submit disabled={!responsesLoaded}>
Submit
</Button>
</div>
</Card>
);
};

export default ApplicationReview;
const ApplicationReviewWrapped = (
props: Omit<ApplicationReviewProps, 'responses' | 'responsesLoaded'>
) => {
const [responses, setResponses] = useState<Record<string, string>>({});
const [responsesLoaded, setResponsesLoaded] = useState(false);

useEffect(() => {
localforage
.getItem<SavedResponses | null>(SAVED_RESPONSES_KEY)
.then(responses => {
if (responses) {
setResponses(responses);
}
})
.finally(() => setResponsesLoaded(true));
}, []);

return <ApplicationReview {...props} responses={responses} responsesLoaded={responsesLoaded} />;
};

export default ApplicationReviewWrapped;
Loading

0 comments on commit e61b34f

Please sign in to comment.