import axios from 'axios'; import clsx from 'clsx'; import Head from 'next/head'; import { useRouter } from 'next/router'; import { useSession } from 'next-auth/react'; import { useEffect, useMemo, useState } from 'react'; import type { SubmitHandler } from 'react-hook-form'; import { useForm } from 'react-hook-form'; import { PaperClipIcon } from '@heroicons/react/24/outline'; import { Button, CheckboxInput, Dialog, Select, TextArea, TextInput, } from '@tih/ui'; import { EXPERIENCE, LOCATION, ROLE, } from '~/components/resumes/browse/resumeConstants'; import { RESUME_STORAGE_KEY } from '~/constants/file-storage-keys'; import { trpc } from '~/utils/trpc'; const FILE_SIZE_LIMIT_MB = 3; const FILE_SIZE_LIMIT_BYTES = FILE_SIZE_LIMIT_MB * 1000000; const TITLE_PLACEHOLDER = 'e.g. Applying for Company XYZ, please help me to review!'; const ADDITIONAL_INFO_PLACEHOLDER = `e.g. I’m applying for company XYZ. I have been resume-rejected by N companies that I have applied for. Please help me to review so company XYZ gives me an interview!`; const FILE_UPLOAD_ERROR = `Please upload a PDF file that is less than ${FILE_SIZE_LIMIT_MB}MB.`; type IFormInput = { additionalInfo?: string; experience: string; file: File; isChecked: boolean; location: string; role: string; title: string; }; export default function SubmitResumeForm() { const { data: session, status } = useSession(); const resumeCreateMutation = trpc.useMutation('resumes.resume.user.create'); const router = useRouter(); const [resumeFile, setResumeFile] = useState<File | null>(); const [isLoading, setIsLoading] = useState(false); const [invalidFileUploadError, setInvalidFileUploadError] = useState< string | null >(null); const [isDialogShown, setIsDialogShown] = useState(false); useEffect(() => { if (status !== 'loading') { if (session?.user?.id == null) { router.push('/api/auth/signin'); } } }, [router, session, status]); const { register, handleSubmit, setValue, reset, formState: { errors, isDirty }, } = useForm<IFormInput>({ defaultValues: { isChecked: false, }, }); const onSubmit: SubmitHandler<IFormInput> = async (data) => { if (resumeFile == null) { console.error('Resume file is empty'); return; } setIsLoading(true); const formData = new FormData(); formData.append('key', RESUME_STORAGE_KEY); formData.append('file', resumeFile); const res = await axios.post('/api/file-storage', formData, { headers: { 'Content-Type': 'multipart/form-data', }, }); const { url } = res.data; resumeCreateMutation.mutate( { additionalInfo: data.additionalInfo, experience: data.experience, location: data.location, role: data.role, title: data.title, url, }, { onError: (error) => { console.error(error); }, onSettled: () => { setIsLoading(false); }, onSuccess: () => { router.push('/resumes'); }, }, ); }; const onUploadFile = (event: React.ChangeEvent<HTMLInputElement>) => { const file = event.target.files?.item(0); if (file == null) { return; } if (file.type !== 'application/pdf' || file.size > FILE_SIZE_LIMIT_BYTES) { setInvalidFileUploadError(FILE_UPLOAD_ERROR); return; } setInvalidFileUploadError(''); setResumeFile(file); }; const onClickReset = () => { if (isDirty || resumeFile != null) { setIsDialogShown(true); } }; const onClickProceedDialog = () => { setIsDialogShown(false); reset(); setResumeFile(null); }; const onClickDownload = async () => { if (resumeFile == null) { return; } const url = window.URL.createObjectURL(resumeFile); const link = document.createElement('a'); link.href = url; link.setAttribute('download', resumeFile.name); // Append to html link element page document.body.appendChild(link); // Start download link.click(); // Clean up and remove the link link.remove(); }; const fileUploadError = useMemo(() => { if (invalidFileUploadError != null) { return invalidFileUploadError; } if (errors?.file != null) { return 'Resume cannot be empty!'; } }, [errors?.file, invalidFileUploadError]); return ( <> <Head> <title>Upload a Resume</title> </Head> <main className="h-[calc(100vh-4rem)] flex-1 overflow-y-scroll"> <section aria-labelledby="primary-heading" className="flex h-full min-w-0 flex-1 flex-col lg:order-last"> <Dialog isShown={isDialogShown} primaryButton={ <Button display="block" label="OK" variant="primary" onClick={onClickProceedDialog} /> } secondaryButton={ <Button display="block" label="Cancel" variant="tertiary" onClick={() => setIsDialogShown(false)} /> } title="Are you sure you want to clear?" onClose={() => setIsDialogShown(false)}> Note that your current input will not be saved! </Dialog> <div className="mx-20 space-y-4 py-8"> <form onSubmit={handleSubmit(onSubmit)}> <h1 className="mb-4 text-2xl font-bold">Upload a resume</h1> <div className="mb-4"> <TextInput {...register('title', { required: true })} disabled={isLoading} label="Title" placeholder={TITLE_PLACEHOLDER} required={true} onChange={(val) => setValue('title', val)} /> </div> <div className="mb-4"> <Select {...register('role', { required: true })} disabled={isLoading} label="Role" options={ROLE} required={true} onChange={(val) => setValue('role', val)} /> </div> <div className="mb-4"> <Select {...register('experience', { required: true })} disabled={isLoading} label="Experience Level" options={EXPERIENCE} required={true} onChange={(val) => setValue('experience', val)} /> </div> <div className="mb-4"> <Select {...register('location', { required: true })} disabled={isLoading} label="Location" name="location" options={LOCATION} required={true} onChange={(val) => setValue('location', val)} /> </div> <p className="text-sm font-medium text-slate-700"> Upload resume (PDF format) <span aria-hidden="true" className="text-danger-500"> {' '} * </span> </p> <div className="mb-4"> <div className={clsx( fileUploadError ? 'border-danger-600' : 'border-gray-300', 'mt-2 flex justify-center rounded-md border-2 border-dashed px-6 pt-5 pb-6', )}> <div className="space-y-1 text-center"> <div className="flex gap-2"> {resumeFile == null ? ( <PaperClipIcon className="m-auto h-8 w-8 text-gray-600" /> ) : ( <div className="flex gap-2"> <p className="cursor-pointer underline underline-offset-1 hover:text-indigo-600" onClick={onClickDownload}> {resumeFile.name} </p> </div> )} </div> <div className="flex justify-center text-sm"> <label className="rounded-md focus-within:outline-none focus-within:ring-2 focus-within:ring-indigo-500 focus-within:ring-offset-2" htmlFor="file-upload"> <p className="cursor-pointer font-medium text-indigo-600 hover:text-indigo-500"> {resumeFile == null ? 'Upload a file' : 'Replace file'} </p> <input {...register('file', { required: true })} accept="application/pdf" className="sr-only" disabled={isLoading} id="file-upload" name="file-upload" type="file" onChange={onUploadFile} /> </label> </div> <p className="text-xs text-gray-500"> PDF up to {FILE_SIZE_LIMIT_MB}MB </p> </div> </div> {fileUploadError && ( <p className="text-danger-600 text-sm">{fileUploadError}</p> )} </div> <div className="mb-8"> <TextArea {...register('additionalInfo')} disabled={isLoading} label="Additional Information" placeholder={ADDITIONAL_INFO_PLACEHOLDER} onChange={(val) => setValue('additionalInfo', val)} /> </div> <div className="mb-4 text-left text-sm text-slate-700"> <h2 className="mb-2 text-xl font-medium"> Submission Guidelines </h2> <p> Before you submit, please review and acknolwedge our <span className="font-bold"> submission guidelines </span> stated below. </p> <p> <span className="text-lg font-bold">• </span> Ensure that you do not divulge any of your <span className="font-bold"> personal particulars</span>. </p> <p> <span className="text-lg font-bold">• </span> Ensure that you do not divulge any <span className="font-bold"> {' '} company's proprietary and confidential information </span> . </p> <p> <span className="text-lg font-bold">• </span> Proof-read your resumes to look for grammatical/spelling errors. </p> </div> <CheckboxInput {...register('isChecked', { required: true })} disabled={isLoading} label="I have read and will follow the guidelines stated." onChange={(val) => setValue('isChecked', val)} /> <div className="mt-4 flex justify-end gap-4"> <Button addonPosition="start" disabled={isLoading} display="inline" label="Clear" size="md" variant="tertiary" onClick={onClickReset} /> <Button addonPosition="start" disabled={isLoading} display="inline" isLoading={isLoading} label="Submit" size="md" type="submit" variant="primary" /> </div> </form> </div> </section> </main> </> ); }