├── additional.d.ts ├── constants ├── forms.json ├── responsive.ts ├── index.ts ├── colors.ts ├── additionalConstants.ts ├── csv_parser.test.ts ├── Animal Form.csv ├── questions.json └── csv_parser.ts ├── public ├── logo.png ├── favicon.ico ├── EdLawLogo.png ├── resources │ ├── formDesign.png │ └── formRoutes.png └── vercel.svg ├── styles ├── themes │ ├── index.ts │ └── getLightTheme.tsx ├── component │ └── StartComplaint.module.css ├── globals.css └── Home.module.css ├── models ├── answer.ts ├── index.ts ├── form.ts └── question.ts ├── .babelrc ├── .husky └── pre-commit ├── .eslintrc ├── next.config.js ├── .prettierrc.json ├── next-env.d.ts ├── docker-compose.yml ├── components ├── LandingPage │ ├── LandingStyles.tsx │ ├── BottomButtonBar.tsx │ ├── SubMenuItem.tsx │ ├── LandingContent.tsx │ ├── LandingSplitPage.tsx │ ├── RightsPrsMenu.tsx │ ├── LandingAboutPrs.tsx │ └── LandingStudentRightsGuide.tsx ├── LoadingSpinner.tsx ├── Critical │ ├── Logo.tsx │ ├── BottomBar.tsx │ ├── SplitPage.tsx │ ├── NavBar.tsx │ ├── FormTemplate.tsx │ └── SideProgressBar.tsx ├── DynamicForm │ ├── MyContinue.tsx │ ├── MyInput.tsx │ ├── Tooltip.tsx │ ├── ChooseFormType.tsx │ ├── MyRadio.tsx │ └── MyResult.tsx ├── FormikExample │ ├── MyCheckbox.tsx │ └── MySelect.tsx ├── FormStyles │ ├── Button.tsx │ ├── TextArea.tsx │ ├── QuestionLayout.tsx │ ├── RadioButton.tsx │ ├── PasswordInputBox.tsx │ ├── QuestionText.tsx │ ├── ExtraStyles.tsx │ ├── InputBox.tsx │ └── ContactInfo.tsx ├── Login │ ├── LoginAbstraction.tsx │ ├── LoginStyling.tsx │ ├── ForgotPassword.tsx │ ├── HatImage.tsx │ ├── SignIn.tsx │ ├── LoginContainer.tsx │ └── SignUp.tsx └── MainPage.tsx ├── utils ├── isSignedIn.ts ├── FormContext.ts └── isWellFormed.ts ├── pages ├── api │ ├── connectTest.ts │ ├── auth │ │ ├── signup.ts │ │ └── [...nextauth].ts │ └── form │ │ ├── retrieve.ts │ │ ├── group │ │ ├── retrieve.ts │ │ └── save.ts │ │ ├── concern │ │ ├── retrieve.ts │ │ └── save.ts │ │ ├── district │ │ ├── retrieve.ts │ │ └── save.ts │ │ ├── additionalinfo │ │ ├── retrieve.ts │ │ └── save.ts │ │ ├── contactinfo │ │ ├── retrieve.ts │ │ └── save.ts │ │ ├── save.ts │ │ └── questions │ │ └── upload.ts ├── prs.tsx ├── info.tsx ├── _app.tsx ├── signin.tsx ├── signup.tsx ├── home.tsx ├── index.tsx ├── forgotpassword.tsx ├── _document.tsx ├── admin.tsx └── form │ ├── README.md │ ├── district.tsx │ ├── group.tsx │ ├── concern.tsx │ ├── contactinfo.tsx │ ├── index.tsx │ └── additionalinfo.tsx ├── types.d.ts ├── .gitignore ├── hooks └── widthHook.ts ├── tsconfig.json ├── jest.config.js ├── server ├── _dbConnect.ts └── crypto.ts ├── LICENSE ├── README.md └── package.json /additional.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*.css' 2 | -------------------------------------------------------------------------------- /constants/forms.json: -------------------------------------------------------------------------------- 1 | { 2 | "animalForm": 0, 3 | "actualForm": 1 4 | } -------------------------------------------------------------------------------- /public/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sandboxnu/edulaw/main/public/logo.png -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sandboxnu/edulaw/main/public/favicon.ico -------------------------------------------------------------------------------- /styles/themes/index.ts: -------------------------------------------------------------------------------- 1 | export { default as getLightTheme } from './getLightTheme' 2 | -------------------------------------------------------------------------------- /public/EdLawLogo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sandboxnu/edulaw/main/public/EdLawLogo.png -------------------------------------------------------------------------------- /constants/responsive.ts: -------------------------------------------------------------------------------- 1 | export const CUTOFFS = { 2 | mobile: 768, 3 | tablet: 768, 4 | } 5 | -------------------------------------------------------------------------------- /models/answer.ts: -------------------------------------------------------------------------------- 1 | export interface Answer { 2 | content?: string 3 | route: number 4 | } 5 | -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["next/babel"], 3 | "plugins": [["styled-components", { "ssr": true }]] 4 | } -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | npx lint-staged 5 | npm test 6 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["plugin:@typescript-eslint/recommended", "prettier", "next/core-web-vitals"] 3 | } 4 | -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | module.exports = { 3 | reactStrictMode: true, 4 | } 5 | -------------------------------------------------------------------------------- /public/resources/formDesign.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sandboxnu/edulaw/main/public/resources/formDesign.png -------------------------------------------------------------------------------- /public/resources/formRoutes.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sandboxnu/edulaw/main/public/resources/formRoutes.png -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "semi": false, 3 | "trailingComma": "es5", 4 | "singleQuote": true, 5 | "tabWidth": 2, 6 | "useTabs": false 7 | } 8 | -------------------------------------------------------------------------------- /models/index.ts: -------------------------------------------------------------------------------- 1 | export type { Question } from './question' 2 | export type { Answer } from './answer' 3 | export { forms } from './form' 4 | export type { Forms } from './form' 5 | -------------------------------------------------------------------------------- /constants/index.ts: -------------------------------------------------------------------------------- 1 | export { default as forms } from './forms.json' 2 | export { default as districts } from './districts.json' 3 | export { default as schools } from './schools.json' 4 | -------------------------------------------------------------------------------- /models/form.ts: -------------------------------------------------------------------------------- 1 | import { forms } from '../constants' 2 | 3 | const typedForms = forms as Forms 4 | 5 | export interface Forms { 6 | animalForm: number 7 | actualForm: number 8 | } 9 | export { typedForms as forms } 10 | -------------------------------------------------------------------------------- /next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | 4 | // NOTE: This file should not be edited 5 | // see https://nextjs.org/docs/basic-features/typescript for more information. 6 | -------------------------------------------------------------------------------- /constants/colors.ts: -------------------------------------------------------------------------------- 1 | export const COLORS = { 2 | EDLAW_BLUE: '#5064C7', 3 | EDLAW_GREEN: '#75BA39', 4 | LIGHT_GREY: '#FAFAFB', 5 | TEXT_GREY: '#A3A3A3', 6 | TEXT_DARKGREY: '#3D3D3D', 7 | SHADOW_GREY: '#E5E5E5', 8 | TOOLTIP_SHADOW: '#5365c10f', 9 | } 10 | -------------------------------------------------------------------------------- /constants/additionalConstants.ts: -------------------------------------------------------------------------------- 1 | // options for the Student or Group details page 2 | export const studentSpecialCircumstances = [ 3 | 'The student has an IEP (individualized education plan)', 4 | 'The student has a 504 plan', 5 | 'The student is home schooled ', 6 | 'The student is being educated through a Home Hospital Program', 7 | ] 8 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | mongo: 3 | container_name: mongo-on-docker 4 | image: mongo:5.0.6 5 | environment: 6 | MONGO_INITDB_ROOT_USERNAME: "${MONGO_INITDB_ROOT_USERNAME}" 7 | MONGO_INITDB_ROOT_PASSWORD: "${MONGO_INITDB_ROOT_PASSWORD}" 8 | ports: 9 | - "8080:27017" 10 | volumes: 11 | - ./edulaw-data:/data/db 12 | 13 | volumes: 14 | edulaw_data: 15 | -------------------------------------------------------------------------------- /components/LandingPage/LandingStyles.tsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components' 2 | 3 | // any shared styles that repeatedly pop up 4 | 5 | export const DiscUl = styled.ul` 6 | margin-left: 20px; 7 | list-style-type: disc; 8 | ` 9 | 10 | export const MainDiv = styled.div` 11 | width: 90%; 12 | margin-left: 20px; 13 | margin-top: 30px; 14 | margin-bottom: 30px; 15 | padding-left: 5%; 16 | padding-right: 3%; 17 | ` 18 | -------------------------------------------------------------------------------- /models/question.ts: -------------------------------------------------------------------------------- 1 | import { Answer } from './answer' 2 | 3 | export enum QuestionType { 4 | RESULT = 'RESULT', 5 | CONTINUE = 'CONTINUE', 6 | RADIO = 'RADIO', 7 | TEXT = 'TEXT', 8 | } 9 | 10 | export interface Question { 11 | id: number 12 | question: string 13 | description?: string 14 | type: QuestionType 15 | answers: Answer[] 16 | tooltip?: { tooltipText: string; tooltipHoveredText: string } 17 | section: string 18 | } 19 | -------------------------------------------------------------------------------- /utils/isSignedIn.ts: -------------------------------------------------------------------------------- 1 | import { Session } from 'next-auth' 2 | 3 | const isSignedIn = ({ 4 | data, 5 | status, 6 | }: { 7 | data: Session | null 8 | status: 'loading' | 'unauthenticated' | 'authenticated' 9 | }): boolean | 'loading' => { 10 | if (status === 'loading') { 11 | return 'loading' 12 | } else { 13 | return status === 'authenticated' && typeof data?.user?.id === 'string' 14 | } 15 | } 16 | 17 | export default isSignedIn 18 | -------------------------------------------------------------------------------- /pages/api/connectTest.ts: -------------------------------------------------------------------------------- 1 | import clientPromise from '../../server/_dbConnect' 2 | import type { NextApiRequest, NextApiResponse } from 'next' 3 | 4 | export default async function handler( 5 | req: NextApiRequest, 6 | res: NextApiResponse 7 | ) { 8 | const client = await clientPromise 9 | if (!client) { 10 | res.status(500).json({ error: 'Client did not connect' }) 11 | } else { 12 | res.status(200).json({ success: 'Client connected successfully' }) 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /pages/prs.tsx: -------------------------------------------------------------------------------- 1 | import type { NextPage } from 'next' 2 | import styles from '../styles/Home.module.css' 3 | import AboutPrs from '../components/LandingPage/LandingAboutPrs' 4 | import LandingSplitPage from '../components/LandingPage/LandingSplitPage' 5 | import React from 'react' 6 | 7 | const AboutPrsPg: NextPage = () => { 8 | return ( 9 |
10 | } /> 11 |
12 | ) 13 | } 14 | 15 | export default AboutPrsPg 16 | -------------------------------------------------------------------------------- /pages/info.tsx: -------------------------------------------------------------------------------- 1 | import type { NextPage } from 'next' 2 | import styles from '../styles/Home.module.css' 3 | import LandingStudentRights from '../components/LandingPage/LandingStudentRightsGuide' 4 | import LandingSplitPage from '../components/LandingPage/LandingSplitPage' 5 | import React from 'react' 6 | 7 | const StudentRights: NextPage = () => { 8 | return ( 9 |
10 | } /> 11 |
12 | ) 13 | } 14 | 15 | export default StudentRights 16 | -------------------------------------------------------------------------------- /types.d.ts: -------------------------------------------------------------------------------- 1 | import type { DefaultUser } from 'next-auth' 2 | import { MongoClient } from 'mongodb' 3 | 4 | declare module 'next-auth' { 5 | interface User extends DefaultUser { 6 | id: string 7 | admin: boolean 8 | } 9 | interface Session { 10 | user?: User 11 | } 12 | } 13 | 14 | declare module 'next-auth/jwt/types' { 15 | interface JWT { 16 | uid: string 17 | admin: boolean 18 | } 19 | } 20 | 21 | declare global { 22 | // eslint-disable-next-line 23 | var _mongoClientPromise: Promise 24 | } 25 | -------------------------------------------------------------------------------- /styles/component/StartComplaint.module.css: -------------------------------------------------------------------------------- 1 | .complaint { 2 | width: 255px; 3 | height: 229px; 4 | background: #fafafb; 5 | border: 0.4px solid #c2c2c2; 6 | box-sizing: border-box; 7 | box-shadow: 2px 2px 15px rgba(0, 0, 0, 0.1); 8 | display: flex; 9 | justify-content: center; 10 | } 11 | 12 | .complaintWrapper { 13 | margin: 10%; 14 | } 15 | 16 | .captionText { 17 | margin-top: 8%; 18 | margin-bottom: 8%; 19 | font-weight: 300; 20 | font-size: 15px; 21 | } 22 | 23 | .buttonCont { 24 | color: #5064c7; 25 | width: 78%; 26 | } 27 | -------------------------------------------------------------------------------- /pages/_app.tsx: -------------------------------------------------------------------------------- 1 | import '../styles/globals.css' 2 | import type { AppProps } from 'next/app' 3 | import React from 'react' 4 | import { ThemeProvider } from '@material-ui/core' 5 | import { getLightTheme } from '../styles/themes/index' 6 | import { SessionProvider } from 'next-auth/react' 7 | 8 | function MyApp({ Component, pageProps }: AppProps) { 9 | return ( 10 | 11 | 12 | 13 | 14 | 15 | ) 16 | } 17 | 18 | export default MyApp 19 | -------------------------------------------------------------------------------- /components/LoadingSpinner.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { TailSpin } from 'react-loader-spinner' 3 | import styled from 'styled-components' 4 | import { COLORS } from '../constants/colors' 5 | const SpinnerWrapper = styled.div` 6 | display: flex; 7 | flex-direction: column; 8 | justify-content: center; 9 | align-items: center; 10 | width: 100%; 11 | height: 100%; 12 | ` 13 | 14 | export const LoadingSpinner: React.FC = () => { 15 | return ( 16 | 17 | 18 | 19 | ) 20 | } 21 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | **/node_modules 5 | /.pnp 6 | .pnp.js 7 | **/.eslintcache 8 | 9 | # testing 10 | /coverage 11 | 12 | # next.js 13 | /.next/ 14 | /out/ 15 | 16 | # production 17 | /build 18 | 19 | # misc 20 | .DS_Store 21 | *.pem 22 | .idea 23 | 24 | # debug 25 | npm-debug.log* 26 | yarn-debug.log* 27 | yarn-error.log* 28 | 29 | # local env files 30 | .env 31 | .env.local 32 | .env.development.local 33 | .env.test.local 34 | .env.production.local 35 | 36 | # vercel 37 | .vercel 38 | 39 | # data 40 | /edulaw-data -------------------------------------------------------------------------------- /pages/signin.tsx: -------------------------------------------------------------------------------- 1 | import SignIn from '../components/Login/SignIn' 2 | import LoginContainer from '../components/Login/LoginContainer' 3 | import React from 'react' 4 | import LoginAbstraction from '../components/Login/LoginAbstraction' 5 | 6 | function signin() { 7 | return ( 8 | } 16 | /> 17 | } 18 | /> 19 | ) 20 | } 21 | 22 | export default signin 23 | -------------------------------------------------------------------------------- /components/Critical/Logo.tsx: -------------------------------------------------------------------------------- 1 | import Image from 'next/image' 2 | 3 | const divStyle = { width: '170px', height: '50px' } // TODO: figure out the right way to do this 4 | 5 | export default function Logo() { 6 | return ( 7 |
14 | EduLaw logo 21 |
22 | ) // TODO: Make mobile responsive 23 | } 24 | -------------------------------------------------------------------------------- /pages/signup.tsx: -------------------------------------------------------------------------------- 1 | import SignUp from '../components/Login/SignUp' 2 | import LoginContainer from '../components/Login/LoginContainer' 3 | import React from 'react' 4 | import LoginAbstraction from '../components/Login/LoginAbstraction' 5 | 6 | function signup() { 7 | return ( 8 | } 16 | /> 17 | } 18 | /> 19 | ) 20 | } 21 | 22 | export default signup 23 | -------------------------------------------------------------------------------- /components/DynamicForm/MyContinue.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from 'react' 2 | import QuestionLayout from '../FormStyles/QuestionLayout' 3 | 4 | interface ContinueProps { 5 | label: string 6 | onMount: () => void 7 | } 8 | 9 | /* 10 | Represents a "Continue" question in the form, which just displays some information and allows user to keep on moving forward 11 | (no answers to choose) 12 | */ 13 | const MyContinue: React.FC = ({ label, onMount }) => { 14 | useEffect(() => { 15 | onMount() 16 | }, []) 17 | 18 | return } /> 19 | } 20 | 21 | export default MyContinue 22 | -------------------------------------------------------------------------------- /components/FormikExample/MyCheckbox.tsx: -------------------------------------------------------------------------------- 1 | import { FieldHookConfig, useField } from 'formik' 2 | import React from 'react' 3 | 4 | interface InputProps { 5 | children: string 6 | } 7 | 8 | export const MyCheckbox: React.FC> = ( 9 | props 10 | ) => { 11 | const [field, meta] = useField({ ...props, type: 'checkbox' }) 12 | return ( 13 |
14 | 18 | 19 | {meta.touched && meta.error ? ( 20 |
{meta.error}
21 | ) : null} 22 |
23 | ) 24 | } 25 | -------------------------------------------------------------------------------- /components/FormStyles/Button.tsx: -------------------------------------------------------------------------------- 1 | import styled, { css } from 'styled-components' 2 | 3 | interface buttonProps { 4 | primary?: boolean 5 | width?: number 6 | } 7 | 8 | export const Button = styled.button` 9 | font-family: 'Source Sans Pro'; 10 | text-align: center; 11 | font-size: 16px; 12 | display: inline-block; 13 | border-radius: 4px; 14 | width: ${({ width }: buttonProps) => (width ? width : 150)}px; 15 | height: 42px; 16 | margin: 0; 17 | &:hover { 18 | cursor: pointer; 19 | } 20 | 21 | ${(props: buttonProps) => 22 | props.primary && 23 | css` 24 | border: none; 25 | background: #5064c7; 26 | color: white; 27 | `}; 28 | ` 29 | -------------------------------------------------------------------------------- /components/LandingPage/BottomButtonBar.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import styled from 'styled-components' 3 | import { 4 | BottomButtonBar, 5 | ButtonContainer, 6 | NextEndButton, 7 | } from '../FormStyles/ExtraStyles' 8 | import Link from 'next/link' 9 | 10 | const StickyBottom = styled(BottomButtonBar)` 11 | position: sticky; 12 | bottom: 0; 13 | ` 14 | 15 | function BottomBar() { 16 | return ( 17 | 18 | 19 | 20 | Continue 21 | 22 | 23 | 24 | ) 25 | } 26 | 27 | export default BottomBar 28 | -------------------------------------------------------------------------------- /constants/csv_parser.test.ts: -------------------------------------------------------------------------------- 1 | import { Question } from '../models' 2 | import { QuestionType } from '../models/question' 3 | import isWellFormed from '../utils/isWellFormed' 4 | import csvParser from './csv_parser' 5 | import expected from './questions.json' 6 | 7 | test('Ensure that the animal flowchart is accurately parsed', async () => { 8 | const actual = csvParser('./Animal Form.csv').questions 9 | expect(actual).toStrictEqual(expected) 10 | }) 11 | 12 | expect.extend({ 13 | toBeWellFormed: isWellFormed, 14 | }) 15 | 16 | test('Ensure animal flowchart is well-formed', async () => { 17 | const actual = csvParser('./Animal Form.csv').questions 18 | expect(actual).toBeWellFormed() 19 | }) 20 | -------------------------------------------------------------------------------- /pages/home.tsx: -------------------------------------------------------------------------------- 1 | import NavBar from '../components/Critical/NavBar' 2 | import MainPage from '../components/MainPage' 3 | import SideProgressBar from '../components/Critical/SideProgressBar' 4 | import React from 'react' 5 | import { 6 | Main, 7 | SidebarDiv, 8 | HorizontalBox, 9 | } from '../components/FormStyles/ExtraStyles' 10 | import { useRouter } from 'next/router' 11 | 12 | function Home() { 13 | return ( 14 |
15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 |
23 | ) 24 | } 25 | 26 | export default Home 27 | -------------------------------------------------------------------------------- /hooks/widthHook.ts: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from 'react' 2 | 3 | export default function useWindowDimensions() { 4 | const [windowDimensions, setWindowDimensions] = useState({ 5 | width: 0, 6 | height: 0, 7 | }) 8 | function handleResize() { 9 | setWindowDimensions(getWindowDimensions()) 10 | } 11 | useEffect(() => { 12 | handleResize() 13 | window.addEventListener('resize', handleResize) 14 | return () => window.removeEventListener('resize', handleResize) 15 | }, []) 16 | 17 | return windowDimensions 18 | } 19 | 20 | function getWindowDimensions() { 21 | const { innerWidth: width, innerHeight: height } = window 22 | return { 23 | width, 24 | height, 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": [ 5 | "dom", 6 | "dom.iterable", 7 | "esnext" 8 | ], 9 | "allowJs": true, 10 | "skipLibCheck": true, 11 | "strict": true, 12 | "forceConsistentCasingInFileNames": true, 13 | "noEmit": true, 14 | "esModuleInterop": true, 15 | "module": "esnext", 16 | "moduleResolution": "node", 17 | "resolveJsonModule": true, 18 | "isolatedModules": true, 19 | "jsx": "preserve", 20 | "incremental": true 21 | }, 22 | "include": [ 23 | "next-env.d.ts", 24 | "additional.d.ts", 25 | "**/*.ts", 26 | "**/*.tsx" 27 | ], 28 | "exclude": [ 29 | "node_modules" 30 | ] 31 | } 32 | -------------------------------------------------------------------------------- /pages/index.tsx: -------------------------------------------------------------------------------- 1 | import type { NextPage } from 'next' 2 | import Head from 'next/head' 3 | import styles from '../styles/Home.module.css' 4 | import LandingContent from '../components/LandingPage/LandingContent' 5 | import LandingSplitPage from '../components/LandingPage/LandingSplitPage' 6 | import React from 'react' 7 | 8 | const Landing: NextPage = () => { 9 | return ( 10 |
11 | 12 | EduLaw 13 | 14 | 15 | 16 | 17 | } /> 18 |
19 | ) 20 | } 21 | 22 | export default Landing 23 | -------------------------------------------------------------------------------- /components/Critical/BottomBar.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | BottomButtonBar, 3 | ButtonContainer, 4 | BackButton, 5 | NextEndButton, 6 | } from '../FormStyles/ExtraStyles' 7 | 8 | interface BottomBarProps { 9 | onBack?: () => void 10 | nextButtonText: string 11 | } 12 | 13 | export const BottomBar: React.FC = ({ 14 | onBack, 15 | nextButtonText, 16 | }) => { 17 | return ( 18 | 19 | 20 | {onBack && ( 21 | 22 | Back 23 | 24 | )} 25 | {nextButtonText} 26 | 27 | 28 | ) 29 | } 30 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line 2 | var nextJest = require('next/jest') 3 | 4 | const createJestConfig = nextJest({ 5 | // Provide the path to your Next.js app to load next.config.js and .env files in your test environment 6 | dir: './', 7 | }) 8 | 9 | // Add any custom config to be passed to Jest 10 | const customJestConfig = { 11 | moduleDirectories: ['node_modules', '/'], 12 | testEnvironment: 'jest-environment-jsdom', 13 | moduleNameMapper: { 14 | '^csv-parse/sync': '/node_modules/csv-parse/dist/cjs/sync.cjs', 15 | }, 16 | } 17 | 18 | // createJestConfig is exported this way to ensure that next/jest can load the Next.js config which is async 19 | module.exports = createJestConfig(customJestConfig) 20 | -------------------------------------------------------------------------------- /styles/globals.css: -------------------------------------------------------------------------------- 1 | @import url('https://fonts.googleapis.com/css2?family=Source+Sans+Pro&display=swap'); 2 | 3 | html, 4 | body { 5 | padding: 0; 6 | margin: 0; 7 | font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Oxygen, 8 | Ubuntu, Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif; 9 | scroll-behavior: smooth; 10 | } 11 | 12 | ul { 13 | list-style-type: circle; 14 | } 15 | 16 | a { 17 | color: blue; 18 | text-decoration: underline; 19 | } 20 | 21 | a:visited { 22 | color: darkblue; 23 | text-decoration: underline; 24 | } 25 | 26 | * { 27 | box-sizing: border-box; 28 | margin: 0; 29 | padding: 0; 30 | } 31 | 32 | .mainpage { 33 | background-color: white; 34 | width: 75%; 35 | height: 100vh; 36 | float: left; 37 | } 38 | -------------------------------------------------------------------------------- /components/Login/LoginAbstraction.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import NavBar from '../Critical/NavBar' 3 | import { COLORS } from '../../constants/colors' 4 | import styled from 'styled-components' 5 | 6 | const LoginPageDiv = styled.div` 7 | width: 100%; 8 | min-height: calc(100vh - 80px); 9 | display: flex; 10 | justify-content: center; 11 | padding-top: 50px; 12 | padding-bottom: 50px; 13 | background-color: ${COLORS.LIGHT_GREY}; 14 | ` 15 | 16 | interface LoginProps { 17 | main: JSX.Element 18 | } 19 | 20 | // component that creates a single point for the styling of the login-related pages to reduce redundancy 21 | function LoginAbstraction(props: LoginProps) { 22 | return ( 23 |
24 | 25 | {props.main} 26 |
27 | ) 28 | } 29 | 30 | export default LoginAbstraction 31 | -------------------------------------------------------------------------------- /components/FormikExample/MySelect.tsx: -------------------------------------------------------------------------------- 1 | import { FieldHookConfig, useField } from 'formik' 2 | import React from 'react' 3 | 4 | interface InputProps { 5 | label: string 6 | values: string[] 7 | } 8 | 9 | export const MySelect: React.FC> = ( 10 | props 11 | ) => { 12 | const [field, meta] = useField(props) 13 | return ( 14 |
15 | 16 | 26 | 27 | {meta.touched && meta.error ? ( 28 |
{meta.error}
29 | ) : null} 30 |
31 | ) 32 | } 33 | -------------------------------------------------------------------------------- /components/LandingPage/SubMenuItem.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import ListItemButton from '@mui/material/ListItemButton' 3 | import ListItemText from '@mui/material/ListItemText' 4 | import Typography from '@mui/material/Typography' 5 | import Link from 'next/link' 6 | 7 | interface MenuItemProps { 8 | label: string 9 | link: string 10 | } 11 | 12 | const SubMenuItem: React.FC = ({ label, link }) => { 13 | return ( 14 | 15 | 16 | 19 |
    20 |
  • {label}
  • 21 |
22 | 23 | } 24 | sx={{ pl: 7 }} 25 | /> 26 |
27 | 28 | ) 29 | } 30 | 31 | export default SubMenuItem 32 | -------------------------------------------------------------------------------- /server/_dbConnect.ts: -------------------------------------------------------------------------------- 1 | import { MongoClient } from 'mongodb' 2 | // Connection URI 3 | const prefix = process.env.NODE_ENV === 'development' ? '' : '+srv' 4 | const root = process.env.MONGO_INITDB_ROOT_USERNAME 5 | const pw = process.env.MONGO_INITDB_ROOT_PASSWORD 6 | const host = 7 | process.env.NODE_ENV === 'development' 8 | ? 'localhost:8080' 9 | : process.env.MONGO_HOST 10 | const uri = `mongodb${prefix}://${root}:${pw}@${host}` 11 | 12 | // stolen from https://github.com/vercel/next.js/tree/canary/examples/with-mongodb 13 | 14 | const client = new MongoClient(uri) 15 | global._mongoClientPromise = client.connect() 16 | const clientPromise = global._mongoClientPromise 17 | .then(async (client) => { 18 | await client.db('admin').command({ ping: 1 }) 19 | return client 20 | }) 21 | .catch((err) => { 22 | return undefined 23 | }) 24 | 25 | // Export a module-scoped MongoClient promise. By doing this in a 26 | // separate module, the client can be shared across functions. 27 | export default clientPromise 28 | -------------------------------------------------------------------------------- /components/FormStyles/TextArea.tsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components' 2 | import { COLORS } from '../../constants/colors' 3 | import { CUTOFFS } from '../../constants/responsive' 4 | 5 | interface TextAreaProps { 6 | width: number 7 | height: number 8 | cutoffWidth?: number 9 | resize?: boolean 10 | } 11 | 12 | export const TextArea = styled.textarea` 13 | width: ${(props: TextAreaProps) => props.width}px; 14 | height: ${(props: TextAreaProps) => props.height}px; 15 | border: 1px solid ${COLORS.SHADOW_GREY}; 16 | background-color: ${COLORS.LIGHT_GREY}; 17 | box-sizing: border-box; 18 | padding: 8px 10px; 19 | border-radius: 6px; 20 | font-size: 16px; 21 | line-height: 24px; 22 | font-family: 'Source Sans Pro'; 23 | resize: ${(props: TextAreaProps) => (props.resize ? 'auto' : 'none')}; 24 | &:focus { 25 | border: 1px solid ${COLORS.EDLAW_BLUE}; 26 | outline: none; 27 | } 28 | @media (max-width: ${CUTOFFS.mobile}px) { 29 | width: ${(props: TextAreaProps) => props.cutoffWidth}px; 30 | } 31 | ` 32 | -------------------------------------------------------------------------------- /server/crypto.ts: -------------------------------------------------------------------------------- 1 | import { 2 | scryptSync, 3 | randomBytes, 4 | createCipheriv, 5 | createDecipheriv, 6 | } from 'crypto' 7 | 8 | const algorithm = 'aes-192-cbc' 9 | const password = process.env.SCRYPT_PASSWORD || 'fallback_password' 10 | 11 | export const encrypt = (value?: string) => { 12 | if (!value) return '' 13 | const salt = randomBytes(16) 14 | const iv = randomBytes(16) 15 | const key = scryptSync(password, salt, 24) 16 | const cipher = createCipheriv(algorithm, key, iv) 17 | let encrypted = cipher.update(value, 'utf8', 'hex') 18 | encrypted += cipher.final('hex') 19 | return `${salt.toString('hex')}:${iv.toString('hex')}:${encrypted}` 20 | } 21 | 22 | export const decrypt = (value?: string) => { 23 | if (!value) return '' 24 | const [salt, iv, encrypted] = value.split(':') 25 | const key = scryptSync(password, Buffer.from(salt, 'hex'), 24) 26 | const decipher = createDecipheriv(algorithm, key, Buffer.from(iv, 'hex')) 27 | let decrypted = decipher.update(encrypted, 'hex', 'utf8') 28 | decrypted += decipher.final('utf8') 29 | return decrypted 30 | } 31 | -------------------------------------------------------------------------------- /public/vercel.svg: -------------------------------------------------------------------------------- 1 | 3 | 4 | -------------------------------------------------------------------------------- /components/FormStyles/QuestionLayout.tsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components' 2 | import { QuestionsWithBlockText } from './QuestionText' 3 | import Tooltip from '../DynamicForm/Tooltip' 4 | 5 | interface QuestionProps { 6 | input: JSX.Element 7 | questionText: string 8 | tooltip?: { tooltipText: string; tooltipHoveredText: string } 9 | results?: JSX.Element[] 10 | } 11 | const QuestionDiv = styled.div` 12 | display: flex; 13 | flex-direction: column; 14 | margin-bottom: 40px; 15 | gap: 20px; 16 | ` 17 | 18 | const InputDiv = styled.div` 19 | display: flex; 20 | gap: 20px; 21 | flex-direction: row; 22 | ` 23 | 24 | // how to attach this to formik? hopefully input will already be attached... 25 | const QuestionLayout: React.FC = ({ 26 | questionText, 27 | tooltip, 28 | results, 29 | input, 30 | }) => { 31 | return ( 32 | 33 | {results} 34 | 35 | 36 | {input} 37 | 38 | ) 39 | } 40 | 41 | export default QuestionLayout 42 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Sandbox 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /pages/api/auth/signup.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable import/no-anonymous-default-export */ 2 | import { NextApiRequest, NextApiResponse } from 'next' 3 | import clientPromise from '../../../server/_dbConnect' 4 | import bcrypt from 'bcryptjs' 5 | 6 | export default async (req: NextApiRequest, res: NextApiResponse) => { 7 | const client = await clientPromise 8 | if (!client) { 9 | res.status(500).json({ error: 'Could not connect to client' }) 10 | return null 11 | } 12 | 13 | const { email, password } = JSON.parse(req.body) 14 | 15 | const users = client.db('edlaw').collection('user') 16 | const exists = await users.findOne({ username: email }) 17 | if (exists) { 18 | res.status(401).json({ error: 'User already exists' }) 19 | } else { 20 | const salt = await bcrypt.genSalt(10) 21 | const hashPass = await bcrypt.hash(password, salt) 22 | const hashedUser = { 23 | username: email, 24 | hashPass, 25 | admin: false, 26 | lastLogin: new Date(Date.now()), 27 | } 28 | const newUser = await users.insertOne(hashedUser) 29 | res.status(200).json({ id: newUser.insertedId }) 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /pages/api/form/retrieve.ts: -------------------------------------------------------------------------------- 1 | import { NextApiRequest, NextApiResponse } from 'next' 2 | import { FormAnswerDB } from './save' 3 | import clientPromise from '../../../server/_dbConnect' 4 | import { unstable_getServerSession } from 'next-auth' 5 | import { authOptions } from '../auth/[...nextauth]' 6 | 7 | export default async function handler( 8 | req: NextApiRequest, 9 | res: NextApiResponse 10 | ) { 11 | if (req.method !== 'GET') { 12 | res.status(400).json({ error: 'Expected GET request' }) 13 | return 14 | } 15 | 16 | const client = await clientPromise 17 | 18 | if (!client) { 19 | res.status(500).json({ error: 'Client is not connected' }) 20 | return 21 | } 22 | 23 | const session = await unstable_getServerSession(req, res, authOptions) 24 | if (!session) { 25 | res.status(401).json({ error: 'You must be logged in.' }) 26 | return 27 | } 28 | const formCollection = client.db('edlaw').collection('form') 29 | const result = (await formCollection.findOne({ 30 | userID: session.user?.id, 31 | })) as FormAnswerDB | null 32 | if (result) { 33 | res.status(200).json(result) 34 | } else { 35 | res.status(401).json({ error: 'User does not have saved formAnswer' }) 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /components/DynamicForm/MyInput.tsx: -------------------------------------------------------------------------------- 1 | import { FieldHookConfig, useField } from 'formik' 2 | import React, { ChangeEvent } from 'react' 3 | import { TextFormAnswer } from '../../utils/FormContext' 4 | import QuestionLayout from '../FormStyles/QuestionLayout' 5 | import { StyledTextInput } from '../FormStyles/InputBox' 6 | 7 | interface InputProps { 8 | name: string 9 | label: string 10 | onChange: (event: ChangeEvent) => void 11 | ans?: TextFormAnswer 12 | tooltip?: { tooltipText: string; tooltipHoveredText: string } 13 | } 14 | 15 | export const MyTextInput: React.FC> = ( 16 | props 17 | ) => { 18 | const [field, meta] = useField(props) 19 | return ( 20 |
21 | 32 | } 33 | /> 34 | {meta.touched && meta.error ? ( 35 |
{meta.error}
36 | ) : null} 37 |
38 | ) 39 | } 40 | -------------------------------------------------------------------------------- /utils/FormContext.ts: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { QuestionType } from '../models/question' 3 | 4 | export interface FormValues { 5 | formAnswers: { 6 | [key: number]: FormAnswer 7 | } 8 | } 9 | 10 | export type FormAnswer = 11 | | RadioFormAnswer 12 | | ContinueQuestionAnswer 13 | | TextFormAnswer 14 | 15 | interface QuestionAnswer { 16 | questionId: number 17 | } 18 | 19 | export interface ContinueQuestionAnswer extends QuestionAnswer { 20 | type: QuestionType.CONTINUE 21 | } 22 | 23 | export interface RadioFormAnswer extends QuestionAnswer { 24 | type: QuestionType.RADIO 25 | answerId: number 26 | } 27 | export interface TextFormAnswer extends QuestionAnswer { 28 | type: QuestionType.TEXT 29 | userAnswer: string 30 | } 31 | 32 | export interface FormResult { 33 | question: string 34 | answer?: string 35 | formAnswer: FormAnswer 36 | } 37 | 38 | // Interface has two parts 39 | export interface FormContextInterface { 40 | formValues: FormValues 41 | setFormValues?: React.Dispatch> 42 | } 43 | 44 | export const emptyFormValues: FormValues = { formAnswers: {} } 45 | 46 | export const defaultFormState: FormContextInterface = { 47 | formValues: emptyFormValues, 48 | } 49 | 50 | export const FormCtx = 51 | React.createContext(defaultFormState) 52 | -------------------------------------------------------------------------------- /styles/themes/getLightTheme.tsx: -------------------------------------------------------------------------------- 1 | import { createTheme, PaletteType } from '@material-ui/core' 2 | 3 | const defaultTheme = createTheme() 4 | 5 | export default function getLightTheme() { 6 | return createTheme({ 7 | palette: { 8 | type: 'light' as PaletteType, 9 | primary: { 10 | light: '#F3F4F6', 11 | main: '#FFFFFF', 12 | dark: '#3D3D3D', 13 | }, 14 | secondary: { 15 | light: '#F1F1F1', 16 | main: '#75BA39', 17 | dark: '#5064C7', 18 | }, 19 | }, 20 | typography: { 21 | fontFamily: 'Source Sans Pro', 22 | h1: { 23 | fontWeight: 600, 24 | fontSize: '40px', 25 | }, 26 | h2: { 27 | fontWeight: 600, 28 | fontSize: '34px', 29 | }, 30 | h4: { 31 | fontWeight: 600, 32 | fontSize: '22px', 33 | }, 34 | h6: { 35 | fontWeight: 300, 36 | fontSize: '18px', 37 | }, 38 | body1: { 39 | fontWeight: 400, 40 | fontSize: '26px', 41 | }, 42 | body2: { 43 | fontWeight: 400, 44 | fontSize: '20px', 45 | }, 46 | caption: { 47 | fontWeight: 700, 48 | fontSize: '18px', 49 | }, 50 | button: { 51 | fontWeight: 600, 52 | fontSize: '24px', 53 | }, 54 | }, 55 | }) 56 | } 57 | -------------------------------------------------------------------------------- /components/Critical/SplitPage.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC } from 'react' 2 | import styled from 'styled-components' 3 | import { CUTOFFS } from '../../constants/responsive' 4 | 5 | // this component has become very much NOT critical due to restyling oops 6 | 7 | const SplitPageResponsive = styled.div` 8 | width: 100%; 9 | height: 100%; 10 | display: flex; 11 | alignitems: stretch; 12 | @media (max-width: ${CUTOFFS.mobile}px) { 13 | flex-direction: column; 14 | justify-content: start; 15 | } 16 | ` 17 | 18 | interface SplitPageProps { 19 | left: JSX.Element 20 | right: JSX.Element 21 | center?: JSX.Element 22 | leftStyle?: React.CSSProperties 23 | rightStyle?: React.CSSProperties 24 | centerStyle?: React.CSSProperties 25 | } 26 | 27 | const SplitPage: FC = ({ 28 | left, 29 | right, 30 | center =
, 31 | leftStyle = { width: '70%', height: '100%', position: 'relative' }, 32 | centerStyle = { width: '0%', height: '100%', position: 'relative' }, 33 | rightStyle = { width: '30%', height: '100%', position: 'relative' }, 34 | }): JSX.Element => { 35 | return ( 36 | 37 |
{left}
38 |
{center}
39 |
{right}
40 |
41 | ) 42 | } 43 | 44 | export default SplitPage 45 | -------------------------------------------------------------------------------- /pages/api/form/group/retrieve.ts: -------------------------------------------------------------------------------- 1 | import { NextApiRequest, NextApiResponse } from 'next' 2 | import { GroupDB } from './save' 3 | import clientPromise from '../../../../server/_dbConnect' 4 | import { unstable_getServerSession } from 'next-auth' 5 | import { authOptions } from '../../auth/[...nextauth]' 6 | import { decrypt } from '../../../../server/crypto' 7 | 8 | export default async function handler( 9 | req: NextApiRequest, 10 | res: NextApiResponse 11 | ) { 12 | if (req.method !== 'GET') { 13 | res.status(400).json({ error: 'Expected GET request' }) 14 | return 15 | } 16 | 17 | const client = await clientPromise 18 | 19 | if (!client) { 20 | res.status(500).json({ error: 'Client is not connected' }) 21 | return 22 | } 23 | 24 | const session = await unstable_getServerSession(req, res, authOptions) 25 | if (!session) { 26 | res.status(401).json({ error: 'You must be logged in.' }) 27 | return 28 | } 29 | const formCollection = client.db('edlaw').collection('group') 30 | const result = (await formCollection.findOne({ 31 | userID: session.user?.id, 32 | })) as GroupDB | null 33 | if (result) { 34 | res 35 | .status(200) 36 | .json({ ...result, studentOrGroup: decrypt(result.studentOrGroup) }) 37 | } else { 38 | res.status(401).json({ error: 'User does not have saved formAnswer' }) 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /pages/api/form/concern/retrieve.ts: -------------------------------------------------------------------------------- 1 | import { NextApiRequest, NextApiResponse } from 'next' 2 | import { ConcernDB } from './save' 3 | import clientPromise from '../../../../server/_dbConnect' 4 | import { unstable_getServerSession } from 'next-auth' 5 | import { authOptions } from '../../auth/[...nextauth]' 6 | import { decrypt } from '../../../../server/crypto' 7 | 8 | export default async function handler( 9 | req: NextApiRequest, 10 | res: NextApiResponse 11 | ) { 12 | if (req.method !== 'GET') { 13 | res.status(400).json({ error: 'Expected GET request' }) 14 | return 15 | } 16 | 17 | const { userID } = req.query 18 | 19 | const client = await clientPromise 20 | 21 | if (!client) { 22 | res.status(500).json({ error: 'Client is not connected' }) 23 | return 24 | } 25 | 26 | const session = await unstable_getServerSession(req, res, authOptions) 27 | if (!session) { 28 | res.status(401).json({ error: 'You must be logged in.' }) 29 | return 30 | } 31 | const formCollection = client.db('edlaw').collection('concern') 32 | const result = (await formCollection.findOne({ 33 | userID: session.user?.id, 34 | })) as ConcernDB | null 35 | if (result) { 36 | res.status(200).json({ _id: result._id, concern: decrypt(result.concern) }) 37 | } else { 38 | res.status(401).json({ error: 'User does not have saved formAnswer' }) 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /pages/api/form/district/retrieve.ts: -------------------------------------------------------------------------------- 1 | import { NextApiRequest, NextApiResponse } from 'next' 2 | import { DistrictDB } from './save' 3 | import clientPromise from '../../../../server/_dbConnect' 4 | import { unstable_getServerSession } from 'next-auth' 5 | import { authOptions } from '../../auth/[...nextauth]' 6 | import { decrypt } from '../../../../server/crypto' 7 | 8 | export default async function handler( 9 | req: NextApiRequest, 10 | res: NextApiResponse 11 | ) { 12 | if (req.method !== 'GET') { 13 | res.status(400).json({ error: 'Expected GET request' }) 14 | return 15 | } 16 | 17 | const client = await clientPromise 18 | 19 | if (!client) { 20 | res.status(500).json({ error: 'Client is not connected' }) 21 | return 22 | } 23 | 24 | const session = await unstable_getServerSession(req, res, authOptions) 25 | if (!session) { 26 | res.status(401).json({ error: 'You must be logged in.' }) 27 | return 28 | } 29 | const formCollection = client.db('edlaw').collection('district') 30 | const result = (await formCollection.findOne({ 31 | userID: session.user?.id, 32 | })) as DistrictDB | null 33 | if (result) { 34 | res.status(200).json({ 35 | ...result, 36 | district: decrypt(result.district), 37 | school: decrypt(result.school), 38 | }) 39 | } else { 40 | res.status(401).json({ error: 'User does not have saved formAnswer' }) 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /components/DynamicForm/Tooltip.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import MuiTooltip from '@mui/material/Tooltip' 3 | import HelpIcon from '@mui/icons-material/Help' 4 | import styled from 'styled-components' 5 | import { COLORS } from '../../constants/colors' 6 | 7 | const TooltipBox = styled.div` 8 | background: #5365c10f; 9 | display: flex; 10 | width: fit-content; 11 | padding-left: 8px; 12 | padding-right: 8px; 13 | border-radius: 4px; 14 | ` 15 | 16 | const StyledIcon = styled(HelpIcon)` 17 | display: flex; 18 | align-items: center; 19 | margin-right: 4px; 20 | padding: 4px; 21 | color: ${COLORS.EDLAW_BLUE}; 22 | ` 23 | const TextStyling = styled.p` 24 | font-size: 12px; 25 | align-self: center; 26 | text-decoration: underline; 27 | color: ${COLORS.EDLAW_BLUE}; 28 | ` 29 | 30 | interface TooltipProps { 31 | tooltip?: { tooltipText: string; tooltipHoveredText: string } 32 | } 33 | 34 | /** 35 | * Represents a tooltip object to give additional popup information for a question 36 | */ 37 | function Tooltip(props: TooltipProps) { 38 | const shouldRender = !!props.tooltip 39 | if (!shouldRender) { 40 | return null 41 | } 42 | 43 | return ( 44 | 45 | 46 | 47 | {props.tooltip!.tooltipText} 48 | 49 | 50 | ) 51 | } 52 | 53 | export default Tooltip 54 | -------------------------------------------------------------------------------- /pages/api/form/additionalinfo/retrieve.ts: -------------------------------------------------------------------------------- 1 | import { NextApiRequest, NextApiResponse } from 'next' 2 | import { AdditionalInfoDb } from './save' 3 | import clientPromise from '../../../../server/_dbConnect' 4 | import { unstable_getServerSession } from 'next-auth' 5 | import { authOptions } from '../../auth/[...nextauth]' 6 | import { decrypt } from '../../../../server/crypto' 7 | 8 | export default async function handler( 9 | req: NextApiRequest, 10 | res: NextApiResponse 11 | ) { 12 | if (req.method !== 'GET') { 13 | res.status(400).json({ error: 'Expected GET request' }) 14 | return 15 | } 16 | 17 | const client = await clientPromise 18 | 19 | if (!client) { 20 | res.status(500).json({ error: 'Client is not connected' }) 21 | return 22 | } 23 | 24 | const session = await unstable_getServerSession(req, res, authOptions) 25 | if (!session) { 26 | res.status(401).json({ error: 'You must be logged in.' }) 27 | return 28 | } 29 | const formCollection = client.db('edlaw').collection('additional') 30 | const result = (await formCollection.findOne({ 31 | userID: session.user?.id, 32 | })) as AdditionalInfoDb | null 33 | if (result) { 34 | const decrypted = result 35 | for (const key in result) { 36 | if (key === '_id' || key === 'userID') continue 37 | decrypted[key] = decrypt(result[key]) 38 | } 39 | res.status(200).json(decrypted) 40 | } else { 41 | res.status(401).json({ error: 'User does not have saved formAnswer' }) 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /pages/forgotpassword.tsx: -------------------------------------------------------------------------------- 1 | import ForgotPassword from '../components/Login/ForgotPassword' 2 | import LoginContainer from '../components/Login/LoginContainer' 3 | import React from 'react' 4 | import SplitPage from '../components/Critical/SplitPage' 5 | import styles from '../styles/Home.module.css' 6 | import NavBar from '../components/Critical/NavBar' 7 | 8 | // currently unreachable due to redesign 9 | function forgotpassword() { 10 | return ( 11 |
12 | 13 | } 15 | right={ 16 | } 22 | /> 23 | } 24 | leftStyle={{ 25 | width: '67%', 26 | height: '100%', 27 | minHeight: '100vh', 28 | position: 'relative', 29 | boxShadow: '0px 3px 4px rgba(0, 0, 0, 0.1)', 30 | display: 'flex', 31 | justifyContent: 'center', 32 | paddingTop: '4%', 33 | }} 34 | rightStyle={{ 35 | width: '33%', 36 | position: 'relative', 37 | backgroundColor: '#F4F5F7', 38 | minHeight: '100vh', 39 | display: 'flex', 40 | justifyContent: 'center', 41 | paddingTop: '50px', 42 | }} 43 | /> 44 |
45 | ) 46 | } 47 | 48 | export default forgotpassword 49 | -------------------------------------------------------------------------------- /pages/api/form/concern/save.ts: -------------------------------------------------------------------------------- 1 | import { NextApiRequest, NextApiResponse } from 'next' 2 | import { WithId, Document } from 'mongodb' 3 | import clientPromise from '../../../../server/_dbConnect' 4 | import { unstable_getServerSession } from 'next-auth' 5 | import { authOptions } from '../../auth/[...nextauth]' 6 | import { encrypt } from '../../../../server/crypto' 7 | 8 | export interface ConcernDB extends WithId { 9 | concern: string 10 | } 11 | 12 | export default async function handler( 13 | req: NextApiRequest, 14 | res: NextApiResponse 15 | ) { 16 | if (req.method !== 'POST') { 17 | res.status(400).json({ error: 'Expected POST request' }) 18 | return 19 | } 20 | 21 | const doc = JSON.parse(req.body) as Omit 22 | const client = await clientPromise 23 | if (!client) { 24 | res.status(500).json({ error: 'Client is not connected' }) 25 | return 26 | } 27 | 28 | const session = await unstable_getServerSession(req, res, authOptions) 29 | if (!session) { 30 | res.status(401).json({ error: 'You must be logged in.' }) 31 | return 32 | } 33 | const formCollection = client.db('edlaw').collection('concern') 34 | const result = await formCollection.replaceOne( 35 | { userID: session.user?.id }, 36 | { 37 | userID: session.user?.id, 38 | concern: encrypt(doc.concern), 39 | }, 40 | { 41 | upsert: true, 42 | } 43 | ) 44 | if (result.acknowledged) { 45 | res.status(200).json({ success: true }) 46 | } else { 47 | res.status(401).json({ error: 'An error occurred while saving' }) 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /pages/api/form/contactinfo/retrieve.ts: -------------------------------------------------------------------------------- 1 | import { NextApiRequest, NextApiResponse } from 'next' 2 | import { ContactInfoDb } from './save' 3 | import clientPromise from '../../../../server/_dbConnect' 4 | import { unstable_getServerSession } from 'next-auth' 5 | import { authOptions } from '../../auth/[...nextauth]' 6 | import { decrypt } from '../../../../server/crypto' 7 | 8 | export default async function handler( 9 | req: NextApiRequest, 10 | res: NextApiResponse 11 | ) { 12 | if (req.method !== 'GET') { 13 | res.status(400).json({ error: 'Expected GET request' }) 14 | return 15 | } 16 | 17 | const session = await unstable_getServerSession(req, res, authOptions) 18 | if (!session) { 19 | res.status(401).json({ error: 'You must be logged in.' }) 20 | return 21 | } 22 | 23 | const client = await clientPromise 24 | if (!client) { 25 | res.status(500).json({ error: 'Client is not connected' }) 26 | return 27 | } 28 | 29 | try { 30 | const formCollection = client.db('edlaw').collection('contact') 31 | const result = (await formCollection.findOne({ 32 | userID: session.user?.id, 33 | })) as ContactInfoDb | null 34 | if (result) { 35 | const decrypted = result 36 | for (const key in result) { 37 | if (key === '_id' || key === 'userID') continue 38 | decrypted[key] = decrypt(result[key]) 39 | } 40 | res.status(200).json(decrypted) 41 | } else { 42 | res.status(401).json({ error: 'User does not have saved formAnswer' }) 43 | } 44 | } catch (err: any) { 45 | res.status(500).json({ error: err }) 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /components/LandingPage/LandingContent.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import Divider from '@mui/material/Divider' 3 | import { MainDiv } from './LandingStyles' 4 | 5 | function LandingContent() { 6 | return ( 7 |
8 | 9 | {/* put this in a stylesheet eventually :D and put the content into a const / cleanup*/} 10 |

Filing a Complaint with the State Department of Education

11 | 12 |
13 |

14 | The Problem Resolution System (PRS) is the Massachusetts Department of 15 | Elementary and Secondary Education's (DESE) system for addressing 16 | complaints about students' education rights and the legal 17 | requirements for education. 18 | 19 | If you think a school or district has violated a student's 20 | rights 21 | 22 | , you can submit a complaint online. 23 |

24 |
25 |

26 | This tool can walk you through the process of writing and submitting a 27 | PRS complaint. Use the navigation bar on the left to learn more about 28 | PRS and the types of situations that the tool covers. 29 |

30 |
31 |

32 | When you are ready, you can select "Continue" at the bottom 33 | to start your complaint. Please note that this resource only applies 34 | to students in Massachusetts. 35 |

36 |

37 |
38 |
39 | ) 40 | } 41 | 42 | export default LandingContent 43 | -------------------------------------------------------------------------------- /components/FormStyles/RadioButton.tsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components' 2 | import { COLORS } from '../../constants/colors' 3 | 4 | export const RadioButton = styled.input` 5 | opacity: 0; 6 | position: fixed; 7 | width: 120px; 8 | height: 42px; 9 | border-radius: 0; 10 | 11 | + label { 12 | background-color: ${COLORS.LIGHT_GREY}; 13 | border-color: ${COLORS.SHADOW_GREY}; 14 | color: ${COLORS.TEXT_GREY}; 15 | border-style: solid; 16 | font-size: 14px; 17 | font-family: 'Source Sans Pro'; 18 | display: flex; 19 | border-radius: 6px; 20 | width: 120px; 21 | height: 42px; 22 | justify-content: center; 23 | align-items: center; 24 | } 25 | 26 | &:checked + label { 27 | border: none; 28 | background: #5064c7; 29 | color: white; 30 | } 31 | ` 32 | 33 | export const RadioInputIcon = styled.span` 34 | border-radius: 50%; 35 | width: 20px; 36 | height: 20px; 37 | box-shadow: inset 0 0 0 1px rgba(16, 22, 26, 0.2), 38 | inset 0 -1px 0 rgba(16, 22, 26, 0.1); 39 | background-color: #f5f8fa; 40 | background-image: linear-gradient( 41 | rgba(255, 255, 255, 0.05), 42 | rgba(255, 255, 255, 0) 43 | ); 44 | margin-right: 8px; 45 | ` 46 | 47 | export const RadioInputCheckedIcon = styled(RadioInputIcon)` 48 | background-color: ${COLORS.EDLAW_BLUE}; 49 | linear-gradient(rgba(255, 255, 255, 0.1), rgba(255, 255, 255, 0)); 50 | &:before { 51 | display: block; 52 | width: 20px; 53 | height: 20px; 54 | background-image: radial-gradient( 55 | rgb(255, 255, 255), 56 | rgb(255, 255, 255) 28%, 57 | transparent 32% 58 | ); 59 | content: ''; 60 | } 61 | ` 62 | -------------------------------------------------------------------------------- /pages/api/form/group/save.ts: -------------------------------------------------------------------------------- 1 | import { NextApiRequest, NextApiResponse } from 'next' 2 | import { WithId, Document } from 'mongodb' 3 | import clientPromise from '../../../../server/_dbConnect' 4 | import { unstable_getServerSession } from 'next-auth' 5 | import { authOptions } from '../../auth/[...nextauth]' 6 | import { encrypt } from '../../../../server/crypto' 7 | 8 | export interface GroupDB extends WithId { 9 | studentOrGroup: string 10 | specialCircumstances: Array 11 | } 12 | 13 | export default async function handler( 14 | req: NextApiRequest, 15 | res: NextApiResponse 16 | ) { 17 | if (req.method !== 'POST') { 18 | res.status(400).json({ error: 'Expected POST request' }) 19 | return 20 | } 21 | 22 | const doc = JSON.parse(req.body) as Omit 23 | const client = await clientPromise 24 | if (!client) { 25 | res.status(500).json({ error: 'Client is not connected' }) 26 | return 27 | } 28 | 29 | const session = await unstable_getServerSession(req, res, authOptions) 30 | if (!session) { 31 | res.status(401).json({ error: 'You must be logged in.' }) 32 | return 33 | } 34 | const formCollection = client.db('edlaw').collection('group') 35 | const result = await formCollection.replaceOne( 36 | { userID: session.user?.id }, 37 | { 38 | ...doc, 39 | studentOrGroup: encrypt(doc.studentOrGroup), 40 | userID: session.user?.id, 41 | }, 42 | { 43 | upsert: true, 44 | } 45 | ) 46 | if (result.acknowledged) { 47 | res.status(200).json({ success: true }) 48 | } else { 49 | res.status(401).json({ error: 'An error occurred while saving' }) 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /components/LandingPage/LandingSplitPage.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import styles from '../../styles/Home.module.css' 3 | import NavBar from '../Critical/NavBar' 4 | import RightsPrsMenu from '../LandingPage/RightsPrsMenu' 5 | import BottomBar from './BottomButtonBar' 6 | import { COLORS } from '../../constants/colors' 7 | import { SidebarDiv, HorizontalBox } from '../FormStyles/ExtraStyles' 8 | import styled from 'styled-components' 9 | import { CUTOFFS } from '../../constants/responsive' 10 | 11 | const SplitPageResponsive = styled.div` 12 | width: 100%; 13 | height: 100%; 14 | display: flex; 15 | alignitems: stretch; 16 | @media (max-width: ${CUTOFFS.mobile}px) { 17 | flex-direction: column; 18 | justify-content: start; 19 | } 20 | ` 21 | 22 | const SidebarLandingDiv = styled(SidebarDiv)` 23 | background-color: ${COLORS.LIGHT_GREY}; 24 | ` 25 | 26 | interface LandingProps { 27 | center: JSX.Element 28 | } 29 | 30 | // component that creates a single point for the styling of the landing-related pages to reduce redundancy 31 | function LandingSplitPage(props: LandingProps) { 32 | return ( 33 |
34 | 35 | 36 | 37 | 38 | 39 | 40 |
41 | {/* force the bottom bar to be at bottom when switching to mobile view */} 42 | {props.center} 43 |
44 |
45 |
46 | 47 |
48 | ) 49 | } 50 | 51 | export default LandingSplitPage 52 | -------------------------------------------------------------------------------- /pages/api/form/district/save.ts: -------------------------------------------------------------------------------- 1 | import { NextApiRequest, NextApiResponse } from 'next' 2 | import { WithId, Document } from 'mongodb' 3 | import clientPromise from '../../../../server/_dbConnect' 4 | import { unstable_getServerSession } from 'next-auth' 5 | import { authOptions } from '../../auth/[...nextauth]' 6 | import { encrypt } from '../../../../server/crypto' 7 | 8 | export interface DistrictDB extends WithId { 9 | district: string 10 | school: string 11 | } 12 | 13 | export default async function handler( 14 | req: NextApiRequest, 15 | res: NextApiResponse 16 | ) { 17 | if (req.method !== 'POST') { 18 | res.status(400).json({ error: 'Expected POST request' }) 19 | return 20 | } 21 | 22 | const doc = JSON.parse(req.body) as Omit 23 | const client = await clientPromise 24 | if (!client) { 25 | res.status(500).json({ error: 'Client is not connected' }) 26 | return 27 | } 28 | 29 | const session = await unstable_getServerSession(req, res, authOptions) 30 | if (!session) { 31 | res.status(401).json({ error: 'You must be logged in.' }) 32 | return 33 | } 34 | const formCollection = client.db('edlaw').collection('district') 35 | const result = await formCollection.replaceOne( 36 | { userID: session.user?.id }, 37 | { 38 | ...doc, 39 | district: encrypt(doc.district), 40 | school: encrypt(doc.school), 41 | userID: session.user?.id, 42 | }, 43 | { 44 | upsert: true, 45 | } 46 | ) 47 | if (result.acknowledged) { 48 | res.status(200).json({ success: true }) 49 | } else { 50 | res.status(401).json({ error: 'An error occurred while saving' }) 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /components/Login/LoginStyling.tsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components' 2 | import { Button } from '../FormStyles/Button' 3 | import { CUTOFFS } from '../../constants/responsive' 4 | 5 | // shared styling for sign-in and sign-up pages 6 | export const Container = styled.div` 7 | background-color: #ffffff; 8 | display: flex; 9 | justify-content: flex-start; 10 | flex-flow: column; 11 | gap: 20px; 12 | align-items: flex-start; 13 | ` 14 | 15 | export const Title = styled.h2` 16 | font-family: Source Sans Pro; 17 | font-style: normal; 18 | font-weight: 600; 19 | font-size: 34px; 20 | line-height: 35px; 21 | ` 22 | 23 | export const SubTitle = styled.p` 24 | margin-bottom: 30px; 25 | ` 26 | export const SubContainer = styled.div` 27 | display: flex; 28 | flex-flow: column; 29 | align-items: flex-end; 30 | justify-content: space-evenly; 31 | gap: 8px; 32 | margin-top: 10px; 33 | margin-bottom: 15px; 34 | ` 35 | 36 | export const EStyledButton = styled(Button)` 37 | margin-top: 15px; 38 | width: 356px; 39 | background-color: #5064c7; 40 | color: #ffffff; 41 | :hover { 42 | box-shadow: 0 12px 16px 0 rgba(0, 0, 0, 0.24), 43 | 0 17px 50px 0 rgba(0, 0, 0, 0.19); 44 | } 45 | @media (max-width: ${CUTOFFS.mobile}px) { 46 | width: 271px; 47 | } 48 | ` 49 | 50 | export const BackButton = styled.button` 51 | border: none; 52 | background: none; 53 | font-family: Source Sans Pro; 54 | font-style: normal; 55 | font-weight: 600; 56 | font-size: 28px; 57 | color: #5064c7; 58 | margin-bottom: 10px; 59 | 60 | :hover { 61 | text-shadow: 2px 2px 4px gray; 62 | } 63 | @media (max-width: ${CUTOFFS.mobile}px) { 64 | font-size: 24px; 65 | } 66 | ` 67 | -------------------------------------------------------------------------------- /components/FormStyles/PasswordInputBox.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react' 2 | import { InputBox, ErrorDiv, InputLine } from '../FormStyles/InputBox' 3 | import VisibilityIcon from '@mui/icons-material/Visibility' 4 | import VisibilityOffIcon from '@mui/icons-material/VisibilityOff' 5 | import styled from 'styled-components' 6 | import { FieldHookConfig, useField } from 'formik' 7 | 8 | export const IconButton = styled.button` 9 | border: none; 10 | background: none; 11 | color: #5064c7; 12 | margin-left: -50px; 13 | margin-right: 25px; 14 | ` 15 | 16 | interface PasswordProps { 17 | width: number 18 | height: number 19 | placeholder: string 20 | cutoffWidth?: number 21 | } 22 | 23 | export const PasswordInputBox: React.FC< 24 | PasswordProps & FieldHookConfig 25 | > = (props) => { 26 | const [visibility, setVisibility] = useState(false) 27 | const [field, meta] = useField(props) 28 | 29 | const toggleVisiblity = () => { 30 | setVisibility(!visibility) 31 | } 32 | 33 | return ( 34 |
35 |
36 | 44 | 45 | {visibility ? : } 46 | 47 |
48 | {meta.touched && meta.error ? ( 49 | {meta.error} 50 | ) : null} 51 |
52 | ) 53 | } 54 | -------------------------------------------------------------------------------- /pages/api/form/save.ts: -------------------------------------------------------------------------------- 1 | import { NextApiRequest, NextApiResponse } from 'next' 2 | import { WithId, Document, RegExpOrString } from 'mongodb' 3 | import { FormAnswer, FormValues } from '../../../utils/FormContext' 4 | import clientPromise from '../../../server/_dbConnect' 5 | import { Question } from '../../../models' 6 | import { unstable_getServerSession } from 'next-auth' 7 | import { authOptions } from '../auth/[...nextauth]' 8 | 9 | export interface FormAnswerDB extends WithId { 10 | formValues: FormValues 11 | questionHistory: Question[] 12 | currentQuestion: Question 13 | currentAnswer: FormAnswer 14 | } 15 | 16 | export default async function handler( 17 | req: NextApiRequest, 18 | res: NextApiResponse 19 | ) { 20 | if (req.method !== 'POST') { 21 | res.status(400).json({ error: 'Expected POST request' }) 22 | return 23 | } 24 | 25 | const doc = JSON.parse(req.body) as Omit 26 | const client = await clientPromise 27 | if (!client) { 28 | res.status(500).json({ error: 'Client is not connected' }) 29 | return 30 | } 31 | 32 | const session = await unstable_getServerSession(req, res, authOptions) 33 | if (!session) { 34 | res.status(401).json({ error: 'You must be logged in.' }) 35 | return 36 | } 37 | const formCollection = await client.db('edlaw').collection('form') 38 | const result = await formCollection.replaceOne( 39 | { userID: session.user?.id }, 40 | { 41 | ...doc, 42 | userID: session.user?.id, 43 | }, 44 | { 45 | upsert: true, 46 | } 47 | ) 48 | if (result.acknowledged) { 49 | res.status(200).json({ success: true }) 50 | } else { 51 | res.status(401).json({ error: 'An error occurred while saving' }) 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /pages/api/form/additionalinfo/save.ts: -------------------------------------------------------------------------------- 1 | import { NextApiRequest, NextApiResponse } from 'next' 2 | import { WithId, Document } from 'mongodb' 3 | import clientPromise from '../../../../server/_dbConnect' 4 | import { unstable_getServerSession } from 'next-auth' 5 | import { authOptions } from '../../auth/[...nextauth]' 6 | import { encrypt } from '../../../../server/crypto' 7 | 8 | export interface AdditionalInfoDb extends WithId { 9 | relationship: string 10 | language: string 11 | deseAccommodations: string 12 | bsea: 'Yes' | 'No' 13 | } 14 | 15 | export default async function handler( 16 | req: NextApiRequest, 17 | res: NextApiResponse 18 | ) { 19 | if (req.method !== 'POST') { 20 | res.status(400).json({ error: 'Expected POST request' }) 21 | return 22 | } 23 | 24 | const doc = JSON.parse(req.body) as Omit 25 | const client = await clientPromise 26 | if (!client) { 27 | res.status(500).json({ error: 'Client is not connected' }) 28 | return 29 | } 30 | 31 | const session = await unstable_getServerSession(req, res, authOptions) 32 | if (!session) { 33 | res.status(401).json({ error: 'You must be logged in.' }) 34 | return 35 | } 36 | const formCollection = client.db('edlaw').collection('additional') 37 | const encrypted = doc 38 | for (const key in doc) { 39 | encrypted[key] = encrypt(doc[key]) 40 | } 41 | const result = await formCollection.replaceOne( 42 | { userID: session.user?.id }, 43 | { 44 | ...encrypted, 45 | userID: session.user?.id, 46 | }, 47 | { 48 | upsert: true, 49 | } 50 | ) 51 | if (result.acknowledged) { 52 | res.status(200).json({ success: true }) 53 | } else { 54 | res.status(401).json({ error: 'An error occurred while saving' }) 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /components/FormStyles/QuestionText.tsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components' 2 | import { COLORS } from '../../constants/colors' 3 | 4 | export const QuestionText = styled.label` 5 | font-family: Source Sans Pro; 6 | font-size: 16px; 7 | line-height: 33px; 8 | ` 9 | export const InfoText = styled.p` 10 | font-family: Source Sans Pro; 11 | font-size: 16px; 12 | line-height: 20px; 13 | margin: 0px; 14 | ` 15 | export const AnswerText = styled.label` 16 | font-family: Source Sans Pro; 17 | font-size: 16px; 18 | line-height: 33px; 19 | font-weight: 600; 20 | ` 21 | export const TitleText = styled.h1` 22 | font-size: 26px; 23 | margin: 64px 10% 0px; 24 | font-family: Source Sans Pro; 25 | whitespace: pre-wrap; 26 | ` 27 | 28 | const QuoteBlockBar = styled.div` 29 | width: 5px; 30 | background: ${COLORS.EDLAW_GREEN}; 31 | border-radius: 5px; 32 | margin-right: 12px; 33 | ` 34 | 35 | const BlockQuoteContainer = styled.div` 36 | display: flex; 37 | padding: 10px, 0px, 10px, 0px; 38 | ` 39 | 40 | interface QuestionTextProps { 41 | questionText: string 42 | } 43 | 44 | export const BlockQuote: React.FC = ({ questionText }) => { 45 | return ( 46 | 47 | 48 | {questionText} 49 | 50 | ) 51 | } 52 | 53 | export const QuestionsWithBlockText: React.FC = ({ 54 | questionText, 55 | }) => { 56 | return ( 57 |
58 | {questionText 59 | .split(/"|“|”/) 60 | .map((text, i) => 61 | i % 2 == 0 ? ( 62 | {text} 63 | ) : ( 64 |
65 | ) 66 | )} 67 |
68 | ) 69 | } 70 | -------------------------------------------------------------------------------- /components/FormStyles/ExtraStyles.tsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components' 2 | import { COLORS } from '../../constants/colors' 3 | import { CUTOFFS } from '../../constants/responsive' 4 | import { Button } from './Button' 5 | 6 | export const Main = styled.div` 7 | display: flex; 8 | flex-direction: column; 9 | height: 100%; 10 | align-items: stretch; 11 | ` 12 | 13 | export const SidebarDiv = styled.div` 14 | min-height: calc(100vh - 80px); 15 | min-width: 25%; 16 | @media (max-width: ${CUTOFFS.mobile}px) { 17 | flex-direction: column; 18 | justify-content: start; 19 | min-height: 30%; 20 | } 21 | ` 22 | 23 | export const HorizontalBox = styled.div` 24 | display: flex; 25 | align-items: stretch; 26 | width: 100%; 27 | flex-direction: row; 28 | height: 100%; 29 | justify-content: center; 30 | @media (max-width: ${CUTOFFS.mobile}px) { 31 | flex-direction: column; 32 | justify-content: start; 33 | } 34 | ` 35 | 36 | export const BottomButtonBar = styled.div` 37 | width: 100%; 38 | display: flex; 39 | justify-content: end; 40 | align-items: center; 41 | height: 80px; 42 | min-height: 80px; 43 | border-top: 1px solid ${COLORS.SHADOW_GREY}; 44 | background-color: ${COLORS.LIGHT_GREY}; 45 | margin-top: auto; 46 | ` 47 | 48 | export const ButtonContainer = styled.div` 49 | margin-right: 80px; 50 | @media (max-width: ${CUTOFFS.mobile}px) { 51 | margin-right: 25px; 52 | } 53 | ` 54 | 55 | export const NextEndButton = styled(Button)` 56 | background: ${COLORS.EDLAW_BLUE}; 57 | color: white; 58 | border: none; 59 | box-shadow: 0 4px 8px 0 rgba(0, 0, 0, 0.2), 0 6px 20px 0 rgba(0, 0, 0, 0.19); 60 | ` 61 | 62 | export const BackButton = styled(Button)` 63 | border: none; 64 | width: 80px; 65 | color: ${COLORS.EDLAW_BLUE}; 66 | background-color: transparent; 67 | ` 68 | -------------------------------------------------------------------------------- /pages/api/form/contactinfo/save.ts: -------------------------------------------------------------------------------- 1 | import { NextApiRequest, NextApiResponse } from 'next' 2 | import { WithId, Document } from 'mongodb' 3 | import clientPromise from '../../../../server/_dbConnect' 4 | import { unstable_getServerSession } from 'next-auth' 5 | import { authOptions } from '../../auth/[...nextauth]' 6 | import { encrypt } from '../../../../server/crypto' 7 | 8 | export interface ContactInfoDb extends WithId { 9 | firstName: string 10 | lastName: string 11 | phoneNum: string 12 | email: string 13 | address: string 14 | city: string 15 | state: string 16 | zip: string 17 | } 18 | 19 | export default async function handler( 20 | req: NextApiRequest, 21 | res: NextApiResponse 22 | ) { 23 | if (req.method !== 'POST') { 24 | res.status(400).json({ error: 'Expected POST request' }) 25 | return 26 | } 27 | 28 | const doc = JSON.parse(req.body) as Omit 29 | const client = await clientPromise 30 | if (!client) { 31 | res.status(500).json({ error: 'Client is not connected' }) 32 | return 33 | } 34 | 35 | const session = await unstable_getServerSession(req, res, authOptions) 36 | if (!session) { 37 | res.status(401).json({ error: 'You must be logged in.' }) 38 | return 39 | } 40 | const formCollection = client.db('edlaw').collection('contact') 41 | const encrypted = doc 42 | for (const key in doc) { 43 | encrypted[key] = encrypt(doc[key]) 44 | } 45 | const result = await formCollection.replaceOne( 46 | { userID: session.user?.id }, 47 | { 48 | ...encrypted, 49 | userID: session.user?.id, 50 | }, 51 | { 52 | upsert: true, 53 | } 54 | ) 55 | if (result.acknowledged) { 56 | res.status(200).json({ success: true }) 57 | } else { 58 | res.status(401).json({ error: 'An error occurred while saving' }) 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /constants/Animal Form.csv: -------------------------------------------------------------------------------- 1 | Id,Name,Shape Library,Page ID,Contained By,Group,Line Source,Line Destination,Source Arrow,Destination Arrow,Status,Text Area 1 2 | 1,Document,,,,,,,,,,Animal Form 3 | 2,Page,,,,,,,,,,Page 1 4 | 3,Rectangle Container,Flowchart Shapes,2,,,,,,,,Pet Lover Section 5 | 4,Process,Flowchart Shapes,2,3,,,,,,,Do you like dogs? 6 | 5,Process,Flowchart Shapes,2,3,,,,,,,Do you have a pet? 7 | 6,Process,Flowchart Shapes,2,3,,,,,,,What about cats? 8 | 7,Process,Flowchart Shapes,2,3,,,,,,,"You identified that you do not like dogs or cats.This language will be used: ""I do not like dogs or cats.""Explain below why you do not like dogs or cats" 9 | 8,Process,Flowchart Shapes,2,3,,,,,,,You are not a fan of animals. 10 | 9,Process,Flowchart Shapes,2,3,,,,,,,You are an animal lover! 11 | 10,Process,Flowchart Shapes,2,3,,,,,,,Do younger siblings count? 12 | 11,Process,Flowchart Shapes,2,3,,,,,,,"No, younger siblings do not count." 13 | 12,Process,Flowchart Shapes,2,3,,,,,,,You should love animals. 14 | 13,Process,Flowchart Shapes,2,3,,,,,,,"You have identified that you don't have a pet. This language will be used: ""I do not have a pet.""Explain below why you do not have a pet. Are you allergic?" 15 | 14,Process,Flowchart Shapes,2,3,,,,,,,Do you have a pet? 16 | 15,Line,,2,,,4,5,None,Arrow,,Yes 17 | 16,Line,,2,,,4,6,None,Arrow,,No 18 | 17,Line,,2,,,6,5,None,Arrow,,I like cats! 19 | 18,Line,,2,,,6,12,None,Arrow,,I do not like cats. 20 | 19,Line,,2,,,5,13,None,Arrow,,No 21 | 20,Line,,2,,,7,14,None,Arrow,,TEXT 22 | 21,Line,,2,,,5,9,None,Arrow,,Yes 23 | 22,Line,,2,,,5,10,None,Measure,,TOOLTIP-TEXT 24 | 23,Line,,2,,,5,11,None,Measure,,TOOLTIP-HOVER-TEXT 25 | 24,Line,,2,,,12,7,None,Arrow,,CONTINUE 26 | 25,Line,,2,,,13,8,None,Arrow,,TEXT 27 | 26,Line,,2,,,14,11,None,Measure,,TOOLTIP-HOVER-TEXT 28 | 27,Line,,2,,,14,10,None,Measure,,TOOLTIP-TEXT 29 | 28,Line,,2,,,14,8,None,Arrow,,Yes 30 | 29,Line,,2,,,14,13,None,Arrow,,No -------------------------------------------------------------------------------- /pages/_document.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import Document, { Html, Head, Main, NextScript } from 'next/document' 3 | import { ServerStyleSheets } from '@material-ui/styles' 4 | 5 | // Taken from https://newbedev.com/react-material-ui-warning-prop-classname-did-not-match 6 | // To avoid console errors relating the SSR 7 | export default class MyDocument extends Document { 8 | render() { 9 | return ( 10 | 11 | 12 | 13 |
14 | 15 | 16 | 17 | ) 18 | } 19 | } 20 | 21 | // `getInitialProps` belongs to `_document` (instead of `_app`), 22 | // it's compatible with server-side generation (SSG). 23 | MyDocument.getInitialProps = async (ctx) => { 24 | // Resolution order 25 | // 26 | // On the server: 27 | // 1. app.getInitialProps 28 | // 2. page.getInitialProps 29 | // 3. document.getInitialProps 30 | // 4. app.render 31 | // 5. page.render 32 | // 6. document.render 33 | // 34 | // On the server with error: 35 | // 1. document.getInitialProps 36 | // 2. app.render 37 | // 3. page.render 38 | // 4. document.render 39 | // 40 | // On the client 41 | // 1. app.getInitialProps 42 | // 2. page.getInitialProps 43 | // 3. app.render 44 | // 4. page.render 45 | 46 | // Render app and page and get the context of the page with collected side effects. 47 | const sheets = new ServerStyleSheets() 48 | const originalRenderPage = ctx.renderPage 49 | 50 | ctx.renderPage = () => 51 | originalRenderPage({ 52 | enhanceApp: (App) => (props) => sheets.collect(), 53 | }) 54 | 55 | const initialProps = await Document.getInitialProps(ctx) 56 | 57 | return { 58 | ...initialProps, 59 | // Styles fragment is rendered after the app and page rendering finish. 60 | styles: [ 61 | ...React.Children.toArray(initialProps.styles), 62 | sheets.getStyleElement(), 63 | ], 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /components/Login/ForgotPassword.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { Form, Formik } from 'formik' 3 | import { StyledTextInput } from '../FormStyles/InputBox' 4 | import * as Yup from 'yup' 5 | import { 6 | BackButton, 7 | EStyledButton, 8 | Container, 9 | Title, 10 | SubTitle, 11 | } from './LoginStyling' 12 | import styled from 'styled-components' 13 | import Link from 'next/link' 14 | 15 | export const TextButtonDiv = styled.div` 16 | display: flex; 17 | flex-flow: column; 18 | justify-content: flex-end; 19 | gap: 25px; 20 | ` 21 | 22 | // component form for the forgot password page 23 | // currently not in use 24 | function ForgotPassword() { 25 | return ( 26 | { 32 | setTimeout(() => { 33 | alert(JSON.stringify(values, null, 2)) 34 | setSubmitting(false) 35 | }, 400) 36 | }} 37 | > 38 |
39 | 40 | 41 | < Back to Login 42 | 43 | Forgot Password? 44 | 45 | No worries. Enter the email you used to create your account and we 46 | will send it over right away!{' '} 47 | 48 | 49 | 56 | 57 | Send 58 | 59 | 60 | 61 |
62 |
63 | ) 64 | } 65 | 66 | export default ForgotPassword 67 | -------------------------------------------------------------------------------- /components/Login/HatImage.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { COLORS } from '../../constants/colors' 3 | 4 | // returns image/svg icon for the 'side box' for the signin/signup page 5 | function HatImage() { 6 | return ( 7 | 14 | 18 | 19 | ) 20 | } 21 | 22 | export default HatImage 23 | -------------------------------------------------------------------------------- /pages/admin.tsx: -------------------------------------------------------------------------------- 1 | import { useSession } from 'next-auth/react' 2 | import { useRouter } from 'next/router' 3 | import React, { useState } from 'react' 4 | import styled from 'styled-components' 5 | import { LoadingSpinner } from '../components/LoadingSpinner' 6 | 7 | const CenterDiv = styled.div` 8 | display: flex; 9 | flex-direction: column; 10 | align-items: center; 11 | height: 20%; 12 | width: 20%; 13 | margin: auto; 14 | margin-top: 5%; 15 | gap: 20px; 16 | ` 17 | 18 | const upload = async (file: File) => { 19 | const text = await file.text() 20 | const response = await fetch('/api/form/questions/upload', { 21 | method: 'POST', 22 | body: text, 23 | }) 24 | if (response.ok) { 25 | alert('Success') 26 | } else { 27 | const { error } = await response.json() 28 | alert(`Error ${response.status}: ${error}`) 29 | } 30 | } 31 | 32 | const Admin = () => { 33 | const { data, status } = useSession() 34 | const router = useRouter() 35 | const [file, setFile] = useState() 36 | const [loading, setLoading] = useState(false) 37 | 38 | if (status === 'loading' || loading) { 39 | return 40 | } else if (status === 'authenticated' && data?.user?.admin) { 41 | return ( 42 | 43 |

Upload csv below

44 | setFile(e.target.files?.item(0) || undefined)} 48 | /> 49 | 65 |
66 | ) 67 | } else { 68 | router.push('/') 69 | return 70 | } 71 | } 72 | 73 | export default Admin 74 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # edulaw 2 | Previews: 3 | 4 | Landing Page: 5 | 6 | 7 | 8 | Start Screen: 9 | 10 | 11 | 12 | Login: 13 | 14 | 15 | 16 | 17 | 18 | EduLaw is a project that assists parents in reporting violations of their children's educational rights to the appropriate legal entity. 19 | 20 | This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app). 21 | 22 | ## Getting Started 23 | 24 | First, run the development server: 25 | 26 | ```bash 27 | npm run dev 28 | # or 29 | yarn dev 30 | ``` 31 | 32 | Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. 33 | 34 | You can start editing the page by modifying `pages/index.tsx`. The page auto-updates as you edit the file. 35 | 36 | [API routes](https://nextjs.org/docs/api-routes/introduction) can be accessed on [http://localhost:3000/api/hello](http://localhost:3000/api/hello). This endpoint can be edited in `pages/api/hello.ts`. 37 | 38 | The `pages/api` directory is mapped to `/api/*`. Files in this directory are treated as [API routes](https://nextjs.org/docs/api-routes/introduction) instead of React pages. 39 | 40 | ## Learn More 41 | 42 | To learn more about Next.js, take a look at the following resources: 43 | 44 | - [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. 45 | - [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. 46 | 47 | You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome! 48 | 49 | ## Deploy on Vercel 50 | 51 | The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. 52 | 53 | Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details. 54 | -------------------------------------------------------------------------------- /styles/Home.module.css: -------------------------------------------------------------------------------- 1 | .container { 2 | min-height: 100vh; 3 | flex-direction: column; 4 | justify-content: center; 5 | height: 100vh; 6 | } 7 | 8 | .main { 9 | flex: 1; 10 | display: flex; 11 | flex-direction: column; 12 | justify-content: center; 13 | align-items: center; 14 | } 15 | 16 | .footer { 17 | width: 100%; 18 | height: 100px; 19 | border-top: 1px solid #eaeaea; 20 | display: flex; 21 | justify-content: center; 22 | align-items: center; 23 | } 24 | 25 | .footer a { 26 | display: flex; 27 | justify-content: center; 28 | align-items: center; 29 | flex-grow: 1; 30 | } 31 | 32 | .title a { 33 | color: #0070f3; 34 | text-decoration: none; 35 | } 36 | 37 | .title a:hover, 38 | .title a:focus, 39 | .title a:active { 40 | text-decoration: underline; 41 | } 42 | 43 | .title { 44 | margin: 0; 45 | line-height: 1.15; 46 | font-size: 4rem; 47 | } 48 | 49 | .title, 50 | .description { 51 | text-align: center; 52 | } 53 | 54 | .description { 55 | line-height: 1.5; 56 | font-size: 1.5rem; 57 | } 58 | 59 | .code { 60 | background: #fafafa; 61 | border-radius: 5px; 62 | padding: 0.75rem; 63 | font-size: 1.1rem; 64 | font-family: Menlo, Monaco, Lucida Console, Liberation Mono, DejaVu Sans Mono, 65 | Bitstream Vera Sans Mono, Courier New, monospace; 66 | } 67 | 68 | .grid { 69 | display: flex; 70 | align-items: center; 71 | justify-content: center; 72 | flex-wrap: wrap; 73 | max-width: 800px; 74 | margin-top: 3rem; 75 | } 76 | 77 | .card { 78 | margin: 1rem; 79 | padding: 1.5rem; 80 | text-align: left; 81 | color: inherit; 82 | text-decoration: none; 83 | border: 1px solid #eaeaea; 84 | border-radius: 10px; 85 | transition: color 0.15s ease, border-color 0.15s ease; 86 | width: 45%; 87 | } 88 | 89 | .card:hover, 90 | .card:focus, 91 | .card:active { 92 | color: #0070f3; 93 | border-color: #0070f3; 94 | } 95 | 96 | .card h2 { 97 | margin: 0 0 1rem 0; 98 | font-size: 1.5rem; 99 | } 100 | 101 | .card p { 102 | margin: 0; 103 | font-size: 1.25rem; 104 | line-height: 1.5; 105 | } 106 | 107 | .logo { 108 | height: 1em; 109 | margin-left: 0.5rem; 110 | } 111 | 112 | @media (max-width: 600px) { 113 | .grid { 114 | width: 100%; 115 | flex-direction: column; 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /pages/form/README.md: -------------------------------------------------------------------------------- 1 | # Dynamically navigated multi-page form: proof of concept 2 | 3 | ## Discussed in standup on Nov 9 4 | 5 | Question types: 6 | 7 | - Radio 8 | - Next 9 | - Next with input 10 | - End (this represents end states that recommend calling the hotline) 11 | - End with summary 12 | 13 | Things to edit in the data structures/models: 14 | 15 | - Add an "Additional Info" (blurb) constants JSON 16 | - Add references to those IDs in the answers model/JSON 17 | 18 | ## Overview 19 | 20 | This branch is not intended to be merged to master, but rather serve as a reference resource to start our work on.The example shown on this branch is a simple form who's path can be seen below: 21 | 22 | ![Form diagram](../../public/resources/formRoutes.png) 23 | 24 | The example can be seen at localhost:3000/DynamicPOC. This proof of concept replaces the brute force solution of having a different Next.js page for each literal question with a solution that has a different page for each different form, and question/answer components get replaced on that page as the user progresses through the form. 25 | 26 | ## Rough baseline data design 27 | 28 | The general idea is to keep all the form content in constant JSON files, and have the actual code select dynamically from them. Our need is for these constants to communicate to the web app rules for both routing to the next question and how to display the current question. 29 | 30 | Below is a proposition and definitely not final starting state for what our design for our constants _could_ look like. It does _not_ take into consideration the actual questions on the form because I opened the document, got scared, and closed it again. Also this is a proof of concept so it's not that deep. 31 | 32 | Also I KNOW that the arrows definitely don't match UML whatever but okay it's fine 33 | 34 | ![Design diagram](../../public/resources/formDesign.png) 35 | 36 | ## Project structure 37 | 38 | The project structure in this current directory (`DynamicPOC`) mimics what our project root could look like (plus more probably). 39 | 40 | - `components` for all components, including styled form components 41 | - `constants` for all form specific text, routing, and display information 42 | - `models` for all models of our constants so that we can translate between JSON and TS. 43 | 44 | ## Things to consider 45 | 46 | - Logic for routing will likely get confusing if the form allows for a "select all that apply" situation. We can try to build data definitions that allow for as much flexibility as possible on this 47 | - Any answer that has an open text input will need to be specially addressed regarding which callback is used to store the answer 48 | -------------------------------------------------------------------------------- /pages/api/auth/[...nextauth].ts: -------------------------------------------------------------------------------- 1 | import NextAuth, { NextAuthOptions } from 'next-auth' 2 | import CredentialProvider from 'next-auth/providers/credentials' 3 | import clientPromise from '../../../server/_dbConnect' 4 | import bcrypt from 'bcryptjs' 5 | import type { WithId, Document } from 'mongodb' 6 | 7 | /* eslint-disable */ 8 | 9 | const signinUser = async (user: WithId, pwd: string) => { 10 | if (!user.hashPass) { 11 | throw new Error('Please enter password') 12 | } 13 | const isMatch = await bcrypt.compare(pwd, user.hashPass) 14 | 15 | if (!isMatch) { 16 | throw new Error('Invalid Credentials') 17 | } 18 | return user 19 | } 20 | 21 | export const authOptions: NextAuthOptions = { 22 | session: { 23 | strategy: 'jwt', 24 | }, 25 | providers: [ 26 | CredentialProvider({ 27 | name: 'Credentials', 28 | id: 'credentials', 29 | credentials: { 30 | username: { label: 'Email', type: 'text', placeholder: '@example.com' }, 31 | password: { label: 'Password', type: 'password' }, 32 | }, 33 | authorize: async (credentials, req) => { 34 | if (!credentials) throw new Error('No credentials provided') 35 | const client = await clientPromise 36 | if (!client) { 37 | return { name: 'Internal Server Error' } 38 | } 39 | const existingUser = await client 40 | .db('edlaw') 41 | .collection('user') 42 | .findOne({ 43 | username: credentials.username, 44 | }) 45 | if (existingUser) { 46 | signinUser(existingUser, credentials.password) 47 | await client 48 | .db('edlaw') 49 | .collection('user') 50 | .updateOne( 51 | { 52 | username: credentials.username, 53 | }, 54 | { 55 | $currentDate: { 56 | lastLogin: true, 57 | }, 58 | } 59 | ) 60 | return { id: existingUser._id, admin: existingUser.admin } 61 | } else { 62 | return { name: 'Invalid Credentials' } 63 | } 64 | }, 65 | }), 66 | ], 67 | callbacks: { 68 | session: async ({ session, token }) => { 69 | if (session.user) { 70 | session.user.id = token.uid 71 | session.user.admin = token.admin 72 | } 73 | return session 74 | }, 75 | jwt: async ({ user, token }) => { 76 | if (user) { 77 | token.uid = user.id 78 | token.admin = user.admin 79 | } 80 | return token 81 | }, 82 | }, 83 | pages: { 84 | signIn: '/signin', 85 | }, 86 | } 87 | 88 | export default NextAuth(authOptions) 89 | 90 | /* eslint-enable */ 91 | -------------------------------------------------------------------------------- /components/DynamicForm/ChooseFormType.tsx: -------------------------------------------------------------------------------- 1 | import React, { ChangeEvent } from 'react' 2 | import { MyTextInput } from './MyInput' 3 | import { Question, Answer } from '../../models' 4 | import { MyRadio } from './MyRadio' 5 | import { MyResult } from './MyResult' 6 | import { FormAnswer } from '../../utils/FormContext' 7 | import MyContinue from './MyContinue' 8 | import { QuestionType } from '../../models/question' 9 | 10 | interface ChooseFormTypeProps { 11 | question: Question 12 | onChange: React.Dispatch> 13 | answer?: FormAnswer 14 | questionHistory: Question[] 15 | } 16 | 17 | export const ChooseFormType: React.FC = ( 18 | props 19 | ): JSX.Element => { 20 | const { answer } = props 21 | const answerChoices: Answer[] = props.question.answers 22 | switch (props.question.type) { 23 | case QuestionType.RADIO: { 24 | return ( 25 | ) => 30 | props.onChange({ 31 | questionId: parseInt(event.target.name), 32 | type: QuestionType.RADIO, 33 | answerId: parseInt(event.target.value), 34 | }) 35 | } 36 | ans={answer?.type === QuestionType.RADIO ? answer : undefined} 37 | tooltip={props.question.tooltip} 38 | /> 39 | ) 40 | } 41 | case QuestionType.TEXT: { 42 | return ( 43 | 48 | ) => 49 | props.onChange({ 50 | questionId: parseInt(event.target.name), 51 | type: QuestionType.TEXT, 52 | userAnswer: event.target.value, 53 | }) 54 | } 55 | ans={answer?.type === QuestionType.TEXT ? answer : undefined} 56 | tooltip={props.question.tooltip} 57 | /> 58 | ) 59 | } 60 | case QuestionType.RESULT: { 61 | return 62 | } 63 | case QuestionType.CONTINUE: { 64 | return ( 65 | 68 | props.onChange({ 69 | questionId: props.question.id, 70 | type: QuestionType.CONTINUE, 71 | }) 72 | } 73 | /> 74 | ) 75 | } 76 | // TODO: Other form types in general 77 | default: { 78 | return
79 | } 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "edulaw", 3 | "version": "1.0.0", 4 | "private": true, 5 | "description": "EduLaw is a project that assists parents in reporting violations of their children's educational rights to the appropriate legal entity.", 6 | "scripts": { 7 | "dev": "npm run db:start && next dev", 8 | "dev2": "npm run && next dev", 9 | "dev:frontend": "next dev", 10 | "stop": "npm run db:stop", 11 | "db:start": "docker-compose -p edlaw -f docker-compose.yml --env-file .env up -d", 12 | "db:stop": "docker-compose -p edlaw -f docker-compose.yml --env-file .env down", 13 | "build": "next build", 14 | "start": "npm run db:start && next start", 15 | "lint": "next lint", 16 | "test": "jest", 17 | "test:watch": "jest --watch", 18 | "prepare": "husky install" 19 | }, 20 | "repository": { 21 | "type": "git", 22 | "url": "git+https://github.com/sandboxnu/edulaw.git" 23 | }, 24 | "author": "sandboxnu", 25 | "license": "ISC", 26 | "bugs": { 27 | "url": "https://github.com/sandboxnu/edulaw/issues" 28 | }, 29 | "homepage": "https://github.com/sandboxnu/edulaw#readme", 30 | "dependencies": { 31 | "@emotion/react": "^11.4.1", 32 | "@emotion/styled": "^11.3.0", 33 | "@material-ui/core": "^4.12.3", 34 | "@material-ui/icons": "^4.11.2", 35 | "@mui/icons-material": "^5.0.5", 36 | "@mui/material": "^5.0.4", 37 | "@mui/styled-engine-sc": "^5.0.3", 38 | "@mui/styles": "^5.0.1", 39 | "@types/csv-parse": "^1.2.2", 40 | "@types/material-ui": "^0.21.9", 41 | "bcrypt": "^5.0.1", 42 | "bcryptjs": "^2.4.3", 43 | "csv-parse": "^5.0.4", 44 | "formik": "^2.2.9", 45 | "jspdf": "^2.5.1", 46 | "mongodb": "^4.4.1", 47 | "mongoose": "^6.2.8", 48 | "next": "^12.1.1", 49 | "next-auth": "^4.6.0", 50 | "react": "17.0.2", 51 | "react-dom": "17.0.2", 52 | "react-loader-spinner": "^5.1.7-beta.1", 53 | "styled-components": "^5.3.3", 54 | "yup": "0.32.11" 55 | }, 56 | "devDependencies": { 57 | "@testing-library/jest-dom": "^5.16.3", 58 | "@testing-library/react": "^12.1.4", 59 | "@types/bcryptjs": "^2.4.2", 60 | "@types/jest": "^27.4.1", 61 | "@types/react": "17.0.24", 62 | "@types/styled-components": "^5.1.17", 63 | "@typescript-eslint/eslint-plugin": "^5.3.1", 64 | "babel-plugin-styled-components": "^2.0.6", 65 | "babel-preset-next": "^1.4.0", 66 | "eslint": "7.32.0", 67 | "eslint-config-next": "11.1.2", 68 | "eslint-config-prettier": "^8.3.0", 69 | "husky": "^7.0.4", 70 | "jest": "^27.5.1", 71 | "lint-staged": "^11.2.6", 72 | "prettier": "2.4.1", 73 | "typescript": "4.4.3" 74 | }, 75 | "lint-staged": { 76 | "*.{js,jsx,ts,tsx}": "eslint --cache --fix", 77 | "*.{js,jsx,ts,tsx,css,md}": "prettier --write" 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /pages/api/form/questions/upload.ts: -------------------------------------------------------------------------------- 1 | import { NextApiRequest, NextApiResponse } from 'next' 2 | import { WithId, Document, MongoClient } from 'mongodb' 3 | import clientPromise from '../../../../server/_dbConnect' 4 | import { unstable_getServerSession } from 'next-auth' 5 | import { authOptions } from '../../auth/[...nextauth]' 6 | import { Question } from '../../../../models' 7 | import csvToQuestionArray from '../../../../constants/csv_parser' 8 | import isWellFormed from '../../../../utils/isWellFormed' 9 | 10 | const dropIfExists = async (collection: string, client: MongoClient) => { 11 | return client 12 | .db('edlaw') 13 | .collections() 14 | .then(async (collections) => { 15 | if (collections.map((c) => c.collectionName).includes(collection)) { 16 | return client.db('edlaw').dropCollection(collection) 17 | } 18 | }) 19 | } 20 | 21 | export default async function handler( 22 | req: NextApiRequest, 23 | res: NextApiResponse 24 | ) { 25 | if (req.method !== 'POST') { 26 | res.status(400).json({ error: 'Expected POST request' }) 27 | return 28 | } 29 | const session = await unstable_getServerSession(req, res, authOptions) 30 | if (!session?.user?.admin) { 31 | res.status(401).json({ error: 'Only admins may access this endpoint' }) 32 | return 33 | } 34 | 35 | const file = req.body as string 36 | 37 | const questionsInfo = csvToQuestionArray(file, { stringified: true }) 38 | const wellFormedResponse = isWellFormed(questionsInfo.questions) 39 | if (!wellFormedResponse.pass) { 40 | res.status(417).json({ 41 | error: `CSV is not well-formed. ${wellFormedResponse.message()}`, 42 | }) 43 | return 44 | } 45 | 46 | const client = await clientPromise 47 | if (!client) { 48 | res.status(500).json({ error: 'Client is not connected' }) 49 | return 50 | } 51 | 52 | await Promise.all( 53 | ['form', 'questions', 'startingQuestion'].map((c) => 54 | dropIfExists(c, client) 55 | ) 56 | ) 57 | 58 | const questionCollection = client.db('edlaw').collection('questions') 59 | const startingQuestionCollection = client 60 | .db('edlaw') 61 | .collection('startingQuestion') 62 | 63 | const result = await questionCollection.insertMany(questionsInfo.questions) 64 | const result2 = await startingQuestionCollection.insertOne({ 65 | index: questionsInfo.startingQuestion, 66 | }) 67 | 68 | try { 69 | await res.revalidate('/form') 70 | } catch (err) { 71 | res 72 | .status(500) 73 | .json({ error: 'Error regenerating form page, please try again.' }) 74 | return 75 | } 76 | 77 | if (result.acknowledged && result2.acknowledged) { 78 | res.status(200).json({ success: true }) 79 | } else { 80 | res.status(401).json({ error: 'An error occurred while saving' }) 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /components/Login/SignIn.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from 'react' 2 | import { Form, Formik, Field } from 'formik' 3 | import { StyledTextInput } from '../FormStyles/InputBox' 4 | import * as Yup from 'yup' 5 | import { EStyledButton, Container, SubContainer } from './LoginStyling' 6 | import { PasswordInputBox } from '../FormStyles/PasswordInputBox' 7 | import { signIn, useSession } from 'next-auth/react' 8 | import { SessionProvider } from 'next-auth/react' 9 | import { NextRouter, useRouter } from 'next/router' 10 | import { Session } from 'next-auth' 11 | 12 | interface FormValues { 13 | email: string 14 | password: string 15 | } 16 | 17 | // checks if the user is able to log in 18 | export function login(data: Session | null, router: NextRouter) { 19 | if (data?.user) { 20 | if (data.user.id) { 21 | if (data.user.admin) { 22 | router.push('/admin') 23 | } else { 24 | router.push('/form/contactinfo') 25 | } 26 | } else { 27 | alert(data.user.name) 28 | data.user = undefined 29 | } 30 | } 31 | } 32 | 33 | // component for the signin page - includes form (and styling) for validation 34 | function SignIn() { 35 | const router = useRouter() 36 | const { data } = useSession() 37 | const initialVal: FormValues = { 38 | email: '', 39 | password: '', 40 | } 41 | 42 | // check if the user is invalid 43 | // checks when data is changed 44 | useEffect(() => login(data, router), [data]) 45 | 46 | return ( 47 | { 55 | // do submission shit here 56 | await signIn('credentials', { 57 | redirect: false, 58 | username: values.email, 59 | password: values.password, 60 | }) 61 | }} 62 | > 63 |
64 | 65 | 66 | 74 | 75 | 82 | 83 | 84 | Sign In 85 | 86 | 87 |
88 |
89 | ) 90 | } 91 | 92 | export default SignIn 93 | -------------------------------------------------------------------------------- /utils/isWellFormed.ts: -------------------------------------------------------------------------------- 1 | import { Question } from '../models' 2 | import { QuestionType } from '../models/question' 3 | 4 | type WellFormedResponse = { 5 | message: string | undefined 6 | pass: boolean 7 | } 8 | 9 | const isWellFormed = (questions: Question[]) => { 10 | const response = questions.reduce( 11 | (soFar, question, idx) => { 12 | if (!soFar.pass) return soFar 13 | if (question.question === '') 14 | return { 15 | message: `Questions checked: ${idx}/${questions.length}\nQuestion ${question.id} has no content`, 16 | pass: false, 17 | } 18 | if (question.question.split('"').length > 3) { 19 | return { 20 | message: `Questions checked: ${idx}/${questions.length}\nQuestion ${question.id} has too many double quotes:\n${question.question}`, 21 | pass: false, 22 | } 23 | } 24 | const outOfBounds = question.answers.find( 25 | (answer) => answer.route < 0 || answer.route >= questions.length 26 | ) 27 | if (outOfBounds) { 28 | return { 29 | message: `Questions checked: ${idx}/${questions.length}\nQuestion ${ 30 | question.id 31 | } has an answer that points out of bounds.\nSection: ${ 32 | question.section 33 | }\nQuestion: ${question.question}\nAnswer: ${JSON.stringify( 34 | outOfBounds 35 | )}`, 36 | pass: false, 37 | } 38 | } 39 | 40 | const answersWellFormed = (q: Question) => { 41 | switch (q.type) { 42 | case QuestionType.RADIO: 43 | return ( 44 | q.answers.length > 0 && 45 | q.answers.every((answer) => answer.content) 46 | ) 47 | case QuestionType.TEXT: 48 | return q.answers.length === 1 && q.answers[0].content === undefined 49 | case QuestionType.CONTINUE: 50 | return q.answers.length === 1 && q.answers[0].content === undefined 51 | case QuestionType.RESULT: 52 | return q.answers.length === 0 53 | } 54 | } 55 | 56 | if (answersWellFormed(question)) { 57 | return soFar 58 | } else { 59 | return { 60 | message: `Questions checked: ${idx}/${questions.length}\nQuestion ${ 61 | question.id 62 | } has malformed answers for type ${question.type}.\nSection: ${ 63 | question.section 64 | }\nQuestion: ${question.question}\nAnswers: ${JSON.stringify( 65 | question.answers 66 | )}`, 67 | pass: false, 68 | } 69 | } 70 | }, 71 | { message: undefined, pass: true } as WellFormedResponse 72 | ) 73 | return { 74 | message: () => response.message ?? `Expected questions to be well-formed`, 75 | pass: response.pass, 76 | } 77 | } 78 | 79 | export default isWellFormed 80 | -------------------------------------------------------------------------------- /components/Critical/NavBar.tsx: -------------------------------------------------------------------------------- 1 | import { AppBar, Toolbar } from '@material-ui/core' 2 | import Typography from '@material-ui/core/Typography' 3 | import { COLORS } from '../../constants/colors' 4 | import HelpOutlineIcon from '@mui/icons-material/HelpOutline' 5 | import styled from 'styled-components' 6 | 7 | import MuiTooltip from '@mui/material/Tooltip' 8 | import Logo from './Logo' 9 | import { signOut as nextAuthSignOut, useSession } from 'next-auth/react' 10 | import { NextRouter, useRouter } from 'next/router' 11 | 12 | const NeedHelpContainer = styled.div` 13 | display: flex; 14 | margin-right: 25px; 15 | align-items: center; 16 | ` 17 | 18 | const StyledHelpIcon = styled.div` 19 | color: white; 20 | margin-right: 8px; 21 | height: 30px; 22 | display: flex; 23 | align-items: center; 24 | ` 25 | 26 | const SignOutButton = styled.button` 27 | font-family: 'Source Sans Pro'; 28 | text-align: center; 29 | font-size: 16px; 30 | display: inline-block; 31 | border-radius: 4px; 32 | border-style: solid; 33 | border-color: ${COLORS.TEXT_DARKGREY}; 34 | width: 100px; 35 | height: 42px; 36 | cursor: pointer; 37 | margin-left: 20px; 38 | color: white; 39 | background-color: ${COLORS.EDLAW_GREEN}; 40 | ` 41 | 42 | const signOut = async (router: NextRouter) => { 43 | const confirm = window.confirm('Are you sure you want to sign out?') 44 | if (!confirm) return 45 | await nextAuthSignOut({ redirect: false }) 46 | router.push('/signin') 47 | } 48 | 49 | function NavBar() { 50 | const router = useRouter() 51 | const { data, status } = useSession() 52 | const tooltipText = ( 53 |

54 | If you feel like the questions in this guide aren't addressing your 55 | concerns, call the EdLaw Project Intake line at{' '} 56 | 617.910.5829, or email us at{' '} 57 | edlawproject@publiccouncil.net 58 |

59 | ) 60 | return ( 61 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | Need help? 78 | 79 | {status === 'authenticated' && ( 80 | signOut(router)}> 81 | Sign out 82 | 83 | )} 84 | 85 | 86 | 87 | 88 | ) 89 | } 90 | 91 | export default NavBar 92 | -------------------------------------------------------------------------------- /constants/questions.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "answers":[ 4 | { 5 | "content":"No", 6 | "route":6 7 | }, 8 | { 9 | "content":"Yes", 10 | "route":4 11 | } 12 | ], 13 | "id":0, 14 | "question":"Do you have a pet?", 15 | "section":"Pet Lover Section", 16 | "tooltip":{ 17 | "tooltipHoveredText":"No, younger siblings do not count.", 18 | "tooltipText":"Do younger siblings count?" 19 | }, 20 | "type":"RADIO" 21 | }, 22 | { 23 | "answers":[ 24 | { 25 | "content":"No", 26 | "route":6 27 | }, 28 | { 29 | "content":"Yes", 30 | "route":5 31 | } 32 | ], 33 | "id":1, 34 | "question":"Do you have a pet?", 35 | "section":"Pet Lover Section", 36 | "tooltip":{ 37 | "tooltipHoveredText":"No, younger siblings do not count.", 38 | "tooltipText":"Do younger siblings count?" 39 | }, 40 | "type":"RADIO" 41 | }, 42 | { 43 | "answers":[ 44 | { 45 | "content":"No", 46 | "route":3 47 | }, 48 | { 49 | "content":"Yes", 50 | "route":0 51 | } 52 | ], 53 | "id":2, 54 | "question":"Do you like dogs?", 55 | "section":"Pet Lover Section", 56 | "type":"RADIO" 57 | }, 58 | { 59 | "answers":[ 60 | { 61 | "content":"I do not like cats.", 62 | "route":8 63 | }, 64 | { 65 | "content":"I like cats!", 66 | "route":0 67 | } 68 | ], 69 | "id":3, 70 | "question":"What about cats?", 71 | "section":"Pet Lover Section", 72 | "type":"RADIO" 73 | }, 74 | { 75 | "answers":[ 76 | 77 | ], 78 | "id":4, 79 | "question":"You are an animal lover!", 80 | "section":"Pet Lover Section", 81 | "type":"RESULT" 82 | }, 83 | { 84 | "answers":[ 85 | 86 | ], 87 | "id":5, 88 | "question":"You are not a fan of animals.", 89 | "section":"Pet Lover Section", 90 | "type":"RESULT" 91 | }, 92 | { 93 | "answers":[ 94 | { 95 | "route":5 96 | } 97 | ], 98 | "id":6, 99 | "question":"You have identified that you don't have a pet. This language will be used: \"I do not have a pet.\"Explain below why you do not have a pet. Are you allergic?", 100 | "section":"Pet Lover Section", 101 | "type":"TEXT" 102 | }, 103 | { 104 | "answers":[ 105 | { 106 | "route":1 107 | } 108 | ], 109 | "id":7, 110 | "question":"You identified that you do not like dogs or cats.This language will be used: \"I do not like dogs or cats.\"Explain below why you do not like dogs or cats", 111 | "section":"Pet Lover Section", 112 | "type":"TEXT" 113 | }, 114 | { 115 | "answers":[ 116 | { 117 | "route":7 118 | } 119 | ], 120 | "id":8, 121 | "question":"You should love animals.", 122 | "section":"Pet Lover Section", 123 | "type":"CONTINUE" 124 | } 125 | ] -------------------------------------------------------------------------------- /components/LandingPage/RightsPrsMenu.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { useRouter } from 'next/router' 3 | import List from '@mui/material/List' 4 | import ListItemButton from '@mui/material/ListItemButton' 5 | import ListItemText from '@mui/material/ListItemText' 6 | import Collapse from '@mui/material/Collapse' 7 | import Divider from '@mui/material/Divider' 8 | import ExpandMore from '@mui/icons-material/ExpandMore' 9 | import ExpandLessIcon from '@mui/icons-material/ExpandLess' 10 | import SubMenuItem from './SubMenuItem' 11 | import Link from 'next/link' 12 | import { COLORS } from '../../constants/colors' 13 | 14 | enum MENU_OPTS { 15 | INFO = '/info', 16 | ABOUT_PRS = '/prs', 17 | } 18 | 19 | function RightsPrsMenu() { 20 | const router = useRouter() 21 | const pathname = router.pathname 22 | 23 | const MainListItem = (label: string, link: string) => { 24 | return ( 25 | 26 | 27 | {pathname === link ? : } 28 | 32 | 33 | 34 | ) 35 | } 36 | 37 | return ( 38 | 47 | {MainListItem('General Information', MENU_OPTS.INFO)} 48 | 49 | 50 | 54 | 58 | 59 | 60 | 61 | 62 | {MainListItem('About PRS', MENU_OPTS.ABOUT_PRS)} 63 | 68 | 69 | 73 | 77 | 81 | 85 | 86 | 87 | 88 | 89 | ) 90 | } 91 | 92 | export default RightsPrsMenu 93 | -------------------------------------------------------------------------------- /components/Login/LoginContainer.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import styled from 'styled-components' 3 | import Link from 'next/link' 4 | import HatImage from './HatImage' 5 | import Divider from '@mui/material/Divider' 6 | import { BackButton } from './LoginStyling' 7 | import ArrowBackIcon from '@mui/icons-material/ArrowBack' 8 | import { CUTOFFS } from '../../constants/responsive' 9 | 10 | export const Container = styled.div` 11 | display: flex; 12 | flex-flow: column; 13 | align-items: flex-start; 14 | ` 15 | 16 | export const SignInUpContainer = styled.div` 17 | width: 394px; 18 | height: 600px; 19 | background-color: #ffffff; 20 | display: flex; 21 | justify-content: space-evenly; 22 | flex-flow: column; 23 | align-items: center; 24 | box-shadow: 0px 0px 15px rgba(160, 160, 160, 0.1); 25 | border-radius: 4px; 26 | @media (max-width: ${CUTOFFS.mobile}px) { 27 | width: 300px; 28 | } 29 | ` 30 | 31 | export const TextDiv = styled.div` 32 | display: flex; 33 | flex-flow: column; 34 | align-items: center; 35 | ` 36 | 37 | export const HeaderStyle = styled.h2` 38 | font-family: Source Sans Pro; 39 | font-style: normal; 40 | font-weight: 600; 41 | font-size: 26px; 42 | line-height: 31px; 43 | margin-bottom: 4%; 44 | ` 45 | 46 | export const SideBoxButton = styled.button` 47 | width: 356px; 48 | height: 42px; 49 | background-color: #ffffff; 50 | border: 1px solid #777777; 51 | box-sizing: border-box; 52 | border-radius: 6px; 53 | @media (max-width: ${CUTOFFS.mobile}px) { 54 | width: 271px; 55 | } 56 | :hover { 57 | box-shadow: 0 12px 16px 0 rgba(0, 0, 0, 0.24), 58 | 0 17px 50px 0 rgba(0, 0, 0, 0.19); 59 | cursor: pointer; 60 | } 61 | ` 62 | 63 | export const AboveButtonText = styled.p` 64 | font-family: Source Sans Pro; 65 | font-style: normal; 66 | font-weight: 600; 67 | font-size: 20px; 68 | line-height: 24px; 69 | text-align: center; 70 | margin-bottom: 4%; 71 | margin-top: 7%; 72 | ` 73 | export const ButtonText = styled.p` 74 | font-family: Source Sans Pro; 75 | font-style: normal; 76 | font-weight: 400; 77 | font-size: 14px; 78 | line-height: 16px; 79 | text-align: center; 80 | ` 81 | 82 | interface LoginSignupProps { 83 | headerPhrase: string 84 | bottomPhrase: string 85 | buttonPhrase: string 86 | buttonLink: string 87 | form: JSX.Element 88 | } 89 | 90 | // functional component for the 'side box' on login/signup pages 91 | function LoginContainer(props: LoginSignupProps) { 92 | return ( 93 | 94 | 95 | 96 | Back to home 97 | 98 | 99 | 100 | 101 | 102 | {props.headerPhrase} 103 | 104 | {props.form} 105 | 106 | 107 | {props.bottomPhrase} 108 | 109 | 110 | {props.buttonPhrase} 111 | 112 | 113 | 114 | 115 | 116 | ) 117 | } 118 | 119 | export default LoginContainer 120 | -------------------------------------------------------------------------------- /components/Login/SignUp.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from 'react' 2 | import { StyledTextInput } from '../FormStyles/InputBox' 3 | import { Form, Formik } from 'formik' 4 | import * as Yup from 'yup' 5 | import { PasswordInputBox } from '../FormStyles/PasswordInputBox' 6 | import { signIn, useSession } from 'next-auth/react' 7 | import { useRouter } from 'next/router' 8 | import { login } from '../Login/SignIn' 9 | import { EStyledButton, Container, SubContainer } from './LoginStyling' 10 | import styled from 'styled-components' 11 | 12 | export const PasswordDiv = styled.div` 13 | display: flex; 14 | flex-flow: row; 15 | justify-content: center; 16 | align-items: flex-start; 17 | gap: 20px; 18 | ` 19 | 20 | interface FormValues { 21 | email: string 22 | password: string 23 | confirmPass: string 24 | } 25 | 26 | // component for signup page - includes form and validation for email and password, 27 | // and ensures that passwords are the same 28 | function Signup() { 29 | const { data } = useSession() 30 | const router = useRouter() 31 | const initialVal: FormValues = { 32 | email: '', 33 | password: '', 34 | confirmPass: '', 35 | } 36 | 37 | // check if the user is invalid 38 | // checks when data is changed 39 | useEffect(() => login(data, router), [data]) 40 | 41 | return ( 42 | { 53 | const result = await fetch('/api/auth/signup', { 54 | method: 'POST', 55 | body: JSON.stringify(values), 56 | }) 57 | 58 | // checks if the user can be added and signs them in 59 | if (result.status === 200) { 60 | await signIn('credentials', { 61 | redirect: false, 62 | username: values.email, 63 | password: values.password, 64 | }) 65 | } else { 66 | const errMessage = await result.json() 67 | alert(errMessage.error) 68 | } 69 | 70 | return result 71 | }} 72 | > 73 |
74 | 75 | 83 | 84 | 91 | 98 | 99 | Sign Up 100 | 101 |
102 |
103 | ) 104 | } 105 | 106 | export default Signup 107 | -------------------------------------------------------------------------------- /components/DynamicForm/MyRadio.tsx: -------------------------------------------------------------------------------- 1 | import { FieldHookConfig, useField } from 'formik' 2 | import React, { ChangeEvent, useEffect } from 'react' 3 | import { Answer } from '../../models' 4 | import { RadioFormAnswer } from '../../utils/FormContext' 5 | import { 6 | RadioButton, 7 | RadioInputCheckedIcon, 8 | RadioInputIcon, 9 | } from '../../components/FormStyles/RadioButton' 10 | import QuestionLayout from '../FormStyles/QuestionLayout' 11 | import Radio from '@mui/material/Radio' 12 | import RadioGroup from '@mui/material/RadioGroup' 13 | import FormControlLabel from '@mui/material/FormControlLabel' 14 | import styled from 'styled-components' 15 | 16 | const StyledRadioText = styled.span` 17 | display: flex; 18 | font-size: 14px; 19 | ` 20 | 21 | const StyledFormControlLabel = styled(FormControlLabel)` 22 | span { 23 | :hover { 24 | background-color: transparent; 25 | } 26 | } 27 | ` 28 | 29 | interface MyRadioProps { 30 | name: string 31 | label: string 32 | options: Answer[] 33 | onChange: (event: ChangeEvent) => void 34 | ans?: RadioFormAnswer 35 | tooltip?: { tooltipText: string; tooltipHoveredText: string } 36 | } 37 | 38 | export const MyRadio: React.FC> = ( 39 | props 40 | ): JSX.Element => { 41 | const [field, meta] = useField(props) 42 | const { ans, name, label, options, onChange, tooltip } = props 43 | // renders input type radio, determines whether or not it should be checked initially 44 | function renderButtonRadio(option: Answer, optionId: number): JSX.Element { 45 | return ( 46 |
47 | 54 | 55 |
56 | ) 57 | } 58 | function renderLongRadio(option: Answer, optionId: number) { 59 | return ( 60 | {option.content || ''}} 65 | control={ 66 | } 68 | checkedIcon={} 69 | disableRipple 70 | {...field} 71 | onChange={onChange} 72 | value={optionId} 73 | /> 74 | } 75 | /> 76 | ) 77 | } 78 | 79 | const renderRadioAnswers = (options: Answer[]) => { 80 | return options.every(({ content }) => content && content.length < 10) ? ( 81 | <>{options.map(renderButtonRadio)} 82 | ) : ( 83 | 88 | {/* Reverse is called here so that y */} 89 | {options.map(renderLongRadio).reverse()} 90 | 91 | ) 92 | } 93 | 94 | return ( 95 |
96 | 101 | 102 | {meta.touched && meta.error ? ( 103 |
{meta.error}
104 | ) : null} 105 |
106 | ) 107 | } 108 | -------------------------------------------------------------------------------- /components/Critical/FormTemplate.tsx: -------------------------------------------------------------------------------- 1 | import SideProgressBar from './SideProgressBar' 2 | import NavBar from './NavBar' 3 | import { CUTOFFS } from '../../constants/responsive' 4 | import styled from 'styled-components' 5 | import { BottomBar } from './BottomBar' 6 | import { Form, Formik } from 'formik' 7 | import { FormValues } from '../../utils/FormContext' 8 | import { LoadingSpinner } from '../LoadingSpinner' 9 | import React, { ReactNode, useEffect } from 'react' 10 | import { useRouter } from 'next/router' 11 | import { TitleText } from '../FormStyles/QuestionText' 12 | import { FormContainer } from '../../pages/form/contactinfo' 13 | import { SidebarDiv } from '../FormStyles/ExtraStyles' 14 | 15 | const FullPageContainer = styled.div` 16 | display: flex; 17 | flex-direction: column; 18 | height: 100vh; 19 | align-items: stretch; 20 | ` 21 | 22 | const HorizontalBox = styled.div` 23 | display: flex; 24 | align-items: stretch; 25 | width: 100%; 26 | flex-direction: row; 27 | height: 100%; 28 | justify-content: center; 29 | @media (max-width: ${CUTOFFS.mobile}px) { 30 | flex-direction: column; 31 | justify-content: start; 32 | } 33 | ` 34 | const FormContentWrapper = styled.div` 35 | height: calc(100vh - 80px); 36 | width: 100%; 37 | display: flex; 38 | flex-direction: column; 39 | justify-content: start; 40 | ` 41 | 42 | const ScrollContainer = styled.div` 43 | margin: 20px 0; 44 | overflow-y: scroll; 45 | ` 46 | 47 | const FormStyled = styled(Form)` 48 | width: 100%; 49 | flex-grow: 1; 50 | display: flex; 51 | ` 52 | 53 | const OtherFormStyled = styled.form` 54 | width: 100%; 55 | flex-grow: 1; 56 | display: flex; 57 | ` 58 | 59 | interface FormTemplateProps { 60 | onSubmit: ( 61 | values: FormValues, 62 | { setSubmitting }: { setSubmitting: (submit: boolean) => void } 63 | ) => void 64 | initialValues?: FormValues 65 | onBack?: () => void 66 | nextButtonText?: string 67 | currentPage?: string 68 | loaded: boolean 69 | onNavigate?: () => Promise 70 | title: string 71 | } 72 | 73 | export const FormTemplate: React.FC = ({ 74 | onBack, 75 | onSubmit, 76 | initialValues, 77 | nextButtonText = 'Next', 78 | children, 79 | currentPage = 'Guided Questions', 80 | loaded, 81 | onNavigate, 82 | title, 83 | }) => { 84 | return ( 85 | 86 | 87 | 88 | {((formElements: React.ReactFragment) => { 89 | return initialValues ? ( 90 | 91 | {formElements} 92 | 93 | ) : ( 94 | { 96 | // eslint-disable-next-line 97 | onSubmit({ formAnswers: {} }, { setSubmitting: (data) => {} }) 98 | evt.preventDefault() 99 | }} 100 | > 101 | {formElements} 102 | 103 | ) 104 | })( 105 | 106 | 107 | 111 | 112 | {!loaded ? ( 113 | 114 | ) : ( 115 | 116 | {title} 117 | 118 | {children} 119 | 120 | 121 | 122 | )} 123 | 124 | )} 125 | 126 | 127 | ) 128 | } 129 | -------------------------------------------------------------------------------- /components/FormStyles/InputBox.tsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components' 2 | import { FieldHookConfig, useField } from 'formik' 3 | import React, { ChangeEvent } from 'react' 4 | import { COLORS } from '../../constants/colors' 5 | import { CUTOFFS } from '../../constants/responsive' 6 | 7 | interface InputBoxProps { 8 | width: number 9 | height: number 10 | cutoffWidth?: number 11 | } 12 | 13 | export const InputBox = styled.textarea` 14 | width: ${(props: InputBoxProps) => props.width}px; 15 | height: ${(props: InputBoxProps) => props.height}px; 16 | border: 1px solid ${COLORS.SHADOW_GREY}; 17 | background-color: ${COLORS.LIGHT_GREY}; 18 | box-sizing: border-box; 19 | padding: 8px 10px; 20 | border-radius: 6px; 21 | font-size: 16px; 22 | line-height: 24px; 23 | font-family: 'Source Sans Pro'; 24 | resize: auto; 25 | &:focus { 26 | border: 1px solid ${COLORS.EDLAW_BLUE}; 27 | outline: none; 28 | } 29 | @media (max-width: ${CUTOFFS.mobile}px) { 30 | width: ${(props: InputBoxProps) => props.cutoffWidth}px; 31 | } 32 | ` 33 | 34 | export const InputLine = styled.input` 35 | width: ${(props: InputBoxProps) => props.width}px; 36 | height: ${(props: InputBoxProps) => props.height}px; 37 | border: 1px solid ${COLORS.SHADOW_GREY}; 38 | background-color: ${COLORS.LIGHT_GREY}; 39 | box-sizing: border-box; 40 | padding: 10px; 41 | border-radius: 6px; 42 | font-size: 16px; 43 | line-height: 26px; 44 | font-family: 'Source Sans Pro'; 45 | resize: none; 46 | &:focus { 47 | border: 1px solid ${COLORS.EDLAW_BLUE}; 48 | outline: none; 49 | } 50 | @media (max-width: ${CUTOFFS.mobile}px) { 51 | width: ${(props: InputBoxProps) => props.cutoffWidth}px; 52 | } 53 | ` 54 | 55 | export const ErrorDiv = styled.div` 56 | color: #ff0000; 57 | ` 58 | 59 | interface InputProps { 60 | name: string 61 | width: number 62 | height: number 63 | defaultValue?: string 64 | onChange?: ( 65 | event: ChangeEvent 66 | ) => void 67 | type?: string 68 | placeholder?: string 69 | cutoffWidth?: number 70 | } 71 | 72 | export const StyledTextInput: React.FC> = ( 73 | props 74 | ) => { 75 | const [field, meta] = useField(props) 76 | 77 | function onChangeInput(): JSX.Element { 78 | if (props.onChange) { 79 | return props.type ? ( 80 | 90 | ) : ( 91 | 100 | ) 101 | } 102 | return props.type ? ( 103 | 112 | ) : ( 113 | 121 | ) 122 | } 123 | 124 | return ( 125 |
126 | {onChangeInput()} 127 | {meta.touched && meta.error ? ( 128 | {meta.error} 129 | ) : null} 130 |
131 | ) 132 | } 133 | -------------------------------------------------------------------------------- /components/MainPage.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { Button } from '@mui/material' 3 | import { withStyles } from '@material-ui/core' 4 | import Typography from '@material-ui/core/Typography' 5 | import Link from 'next/link' 6 | import styled from 'styled-components' 7 | import { COLORS } from '../constants/colors' 8 | 9 | const GradientDiv = styled.div` 10 | background: linear-gradient( 11 | to right, 12 | ${COLORS.EDLAW_GREEN} 0.7%, 13 | white 0.7%, 14 | white 100% 15 | ); 16 | padding: 15px; 17 | border-radius: 6px; 18 | ` 19 | 20 | function MainPage() { 21 | const ContinueButton = withStyles( 22 | { 23 | root: { 24 | backgroundColor: '#5064C7', 25 | width: '100%', 26 | color: '#FFFFFF', 27 | padding: '10px', 28 | borderRadius: '8px', 29 | margin: '0 auto', 30 | '&:hover': { 31 | backgroundColor: '#5064C7', 32 | }, 33 | }, 34 | }, 35 | { name: 'ContinueButtonMain' } 36 | )(Button) 37 | 38 | return ( 39 |
40 |
49 | {' '} 50 | {/* in order to place start text in correct place*/} 51 |

File a new complaint with PRS

52 |
53 |

54 | The Problem Resolution System (PRS) is the Department of Elementary 55 | and Secondary Education's (DESE) system for addressing complaints 56 | about students’ educational rights. More information about PRS and 57 | what happens when you file a complaint is available{' '} 58 | 59 | here. 60 | 61 |

62 |

63 |

64 | This guide will walk you through the process of filing a complaint. 65 | After getting your background information, we'll go through a 66 | series of questions about your concerns, and 67 | suggest legal language to include in your complaint if the 68 | guide identifies that a legal right may have been violated. If 69 | you'd rather file the complaint with PRS directly (without the 70 | suggestions in this guide) you can do so{' '} 71 | here. 72 |

73 |


74 | 75 | Please note: This guide is intended to be a tool to help 76 | families fill out a PRS complaint. It is not legal advice and we 77 | cannot guarantee a particular outcome or that this process will get 78 | you the outcome you want. 79 | 80 |

81 |

82 | Still, filing a PRS complaint is important because DESE is not aware 83 | of problems in the district unless people file complaints, and your 84 | complaint may help not only your student but also other students 85 | across Massachusetts. If you have questions, or if the tool is not 86 | addressing your concerns as you're going through the questions to 87 | write the complaint, call the EdLaw Project intake line at (617) 88 | 910-5829, or fill out the Helpline Intake Form{' '} 89 | here. 90 |

91 |

92 | 93 | 94 | 98 | Start the walkthrough 99 | 100 | 101 | 102 |
103 |
104 | ) 105 | } 106 | 107 | export default MainPage 108 | -------------------------------------------------------------------------------- /components/DynamicForm/MyResult.tsx: -------------------------------------------------------------------------------- 1 | import React, { ChangeEvent, useContext } from 'react' 2 | import { 3 | FormContextInterface, 4 | FormCtx, 5 | FormResult, 6 | FormValues, 7 | } from '../../utils/FormContext' 8 | import { Question } from '../../models' 9 | import { QuestionsWithBlockText, AnswerText } from '../FormStyles/QuestionText' 10 | import styled from 'styled-components' 11 | import { StyledTextInput } from '../FormStyles/InputBox' 12 | import { QuestionType } from '../../models/question' 13 | import QuestionLayout from '../FormStyles/QuestionLayout' 14 | 15 | const SingleQuestionResponseDiv = styled.div` 16 | display: flex; 17 | flex-direction: column; 18 | ` 19 | interface MyResultProps { 20 | label: string 21 | questionHistory: Question[] 22 | } 23 | 24 | // translates ID-based results to content-based results 25 | export function buildResults( 26 | formValues: FormValues, 27 | questionHistory: Question[] 28 | ): FormResult[] { 29 | const results = questionHistory.reduce( 30 | (results: FormResult[], curQuestion) => { 31 | const curFormAns = formValues.formAnswers[curQuestion.id] 32 | // filter out everything but type TEXT 33 | if ( 34 | curFormAns === undefined || 35 | curFormAns.type === QuestionType.CONTINUE || 36 | curFormAns.type === QuestionType.RADIO 37 | ) 38 | return results 39 | 40 | const contentBasedFormAnswer: FormResult = { 41 | answer: undefined, 42 | question: _filterQuotes(curQuestion.question), 43 | formAnswer: curFormAns, 44 | } 45 | results.push(contentBasedFormAnswer) 46 | return results 47 | }, 48 | [] 49 | ) 50 | return results 51 | } 52 | 53 | // returns anything between the "" of a string 54 | function _filterQuotes(question: string): string { 55 | const first = question.indexOf('"') 56 | const start = first + 1 57 | const second = question.substring(start).indexOf('"') 58 | return question.substring(start, start + second) 59 | } 60 | 61 | // updates the form values for the given question in the given context with the contents of the given event 62 | function _updateTextInputs( 63 | ctx: FormContextInterface, 64 | questionId: number, 65 | updatedUserInput: string 66 | ) { 67 | const formValues: FormValues = ctx.formValues 68 | if ( 69 | ctx.setFormValues && 70 | formValues.formAnswers[questionId].type === QuestionType.TEXT 71 | ) { 72 | ctx.setFormValues({ 73 | formAnswers: { 74 | ...formValues.formAnswers, 75 | [questionId]: { 76 | questionId: questionId, 77 | type: QuestionType.TEXT, 78 | userAnswer: updatedUserInput, 79 | }, 80 | }, 81 | }) 82 | } 83 | } 84 | 85 | export const MyResult: React.FC = (props): JSX.Element => { 86 | const ctx = useContext(FormCtx) 87 | const results = buildResults(ctx.formValues, props.questionHistory).map( 88 | ({ formAnswer, question, answer }) => { 89 | function _onChange( 90 | event: ChangeEvent 91 | ) { 92 | _updateTextInputs(ctx, formAnswer.questionId, event.target.value) 93 | } 94 | 95 | return ( 96 |
97 | 98 | 99 | {answer} 100 | 101 | 102 | {formAnswer.type == QuestionType.TEXT ? ( 103 | 111 | ) : null} 112 |
113 | ) 114 | } 115 | ) 116 | 117 | return ( 118 | } 122 | /> 123 | ) 124 | } 125 | -------------------------------------------------------------------------------- /pages/form/district.tsx: -------------------------------------------------------------------------------- 1 | import { MenuItem, TextField } from '@material-ui/core' 2 | import { useSession } from 'next-auth/react' 3 | import { useRouter } from 'next/router' 4 | import React, { useState, useEffect } from 'react' 5 | import { FormTemplate } from '../../components/Critical/FormTemplate' 6 | import { InfoText } from '../../components/FormStyles/QuestionText' 7 | import { TextArea } from '../../components/FormStyles/TextArea' 8 | import { districts, schools } from '../../constants' 9 | import { DistrictDB } from '../api/form/district/save' 10 | import { StyledAutocomplete, StyledTextField } from './additionalinfo' 11 | import { FormContainer, InputCol, InputRow } from './contactinfo' 12 | import isSignedIn from '../../utils/isSignedIn' 13 | 14 | const District: React.FC = () => { 15 | const [district, setDistrict] = useState(undefined) 16 | const [school, setSchool] = useState(undefined) 17 | const router = useRouter() 18 | const { data, status } = useSession() 19 | const [loaded, setLoaded] = useState(false) 20 | 21 | // reroutes to signup if not logged in 22 | if (!isSignedIn({ data, status })) { 23 | router.push('/signup') 24 | } 25 | 26 | // saves values to database 27 | const save = async () => { 28 | if (!data?.user?.id) { 29 | return 30 | } 31 | const body: Omit = { 32 | district: district, 33 | school: school, 34 | } 35 | const result = await fetch('/api/form/district/save', { 36 | method: 'POST', 37 | body: JSON.stringify(body), 38 | }) 39 | const resBody = await result.json() 40 | if (result.status !== 200) { 41 | console.error(resBody.error) 42 | } 43 | } 44 | 45 | // loads values in from database, only loads once 46 | useEffect(() => { 47 | const retrieve = async () => { 48 | if (!data?.user?.id || loaded) { 49 | return 50 | } 51 | const result = await fetch(`/api/form/district/retrieve`) 52 | const body = await result.json() 53 | if (result.status !== 200) { 54 | console.error(body.error) 55 | } else { 56 | const typedBody = body as DistrictDB 57 | setDistrict(typedBody.district) 58 | setSchool(typedBody.school) 59 | } 60 | setLoaded(true) 61 | } 62 | if (!loaded) { 63 | retrieve() 64 | } 65 | }, [data]) 66 | 67 | return ( 68 | { 73 | await save() 74 | router.push('/form/group') 75 | actions.setSubmitting(false) 76 | }} 77 | onBack={async () => { 78 | await save() 79 | router.push('/form/additionalinfo') 80 | }} 81 | currentPage="District and School" 82 | > 83 | 84 | 85 | 86 | Please fill in the student’s school district: 87 | 88 | { 92 | console.log(evt) 93 | console.log(newValue) 94 | setDistrict(newValue as string) 95 | setSchool(undefined) 96 | }} 97 | sx={{ width: 330, height: 42 }} 98 | renderInput={(params) => } 99 | /> 100 | 101 | 102 | 103 | Please fill in the school that the student attends: 104 | 105 | { 111 | setSchool(newValue as string) 112 | }} 113 | sx={{ width: 330, height: 42 }} 114 | renderInput={(params) => } 115 | /> 116 | 117 | 118 | 119 | ) 120 | } 121 | 122 | export default District 123 | -------------------------------------------------------------------------------- /pages/form/group.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react' 2 | import { GroupDB } from '../api/form/group/save' 3 | import { useSession } from 'next-auth/react' 4 | import { useRouter } from 'next/router' 5 | import { studentSpecialCircumstances } from '../../constants/additionalConstants' 6 | import { FormTemplate } from '../../components/Critical/FormTemplate' 7 | import { StyledAutocomplete, StyledTextField } from './additionalinfo' 8 | import { Checkbox, MenuItem, TextField } from '@material-ui/core' 9 | import { InfoText } from '../../components/FormStyles/QuestionText' 10 | import { TextArea } from '../../components/FormStyles/TextArea' 11 | import { InputCol } from './contactinfo' 12 | import isSignedIn from '../../utils/isSignedIn' 13 | 14 | const Group: React.FC = () => { 15 | const [studentOrGroup, setStudentOrGroup] = useState() 16 | const [checkedArr, setCheckedArr] = useState>([ 17 | false, 18 | false, 19 | false, 20 | false, 21 | ]) 22 | const router = useRouter() 23 | const { data, status } = useSession() 24 | const [loaded, setLoaded] = useState(false) 25 | 26 | if (!isSignedIn({ data, status })) { 27 | router.push('/signup') 28 | } 29 | 30 | const save = async () => { 31 | if (!data?.user?.id) { 32 | return 33 | } 34 | const body: Omit = { 35 | studentOrGroup: studentOrGroup, 36 | specialCircumstances: checkedArr, 37 | } 38 | const result = await fetch('/api/form/group/save', { 39 | method: 'POST', 40 | body: JSON.stringify(body), 41 | }) 42 | const resBody = await result.json() 43 | if (result.status !== 200) { 44 | console.error(resBody.error) 45 | } 46 | } 47 | 48 | useEffect(() => { 49 | const retrieve = async () => { 50 | if (!data?.user?.id || loaded) { 51 | return 52 | } 53 | const result = await fetch(`/api/form/group/retrieve`) 54 | const body = await result.json() 55 | if (result.status !== 200) { 56 | console.error(body.error) 57 | } else { 58 | const typedBody = body as GroupDB 59 | setStudentOrGroup(typedBody.studentOrGroup) 60 | setCheckedArr(typedBody.specialCircumstances) 61 | } 62 | setLoaded(true) 63 | } 64 | if (!loaded) { 65 | retrieve() 66 | } 67 | }, [data]) 68 | 69 | const handleOnChange = (position: number) => { 70 | const updatedCheckedArr = [ 71 | ...checkedArr.slice(0, position), 72 | !checkedArr[position], 73 | ...checkedArr.slice(position + 1), 74 | ] 75 | setCheckedArr(updatedCheckedArr) 76 | } 77 | 78 | return ( 79 | { 84 | await save() 85 | router.push('/form/concern') 86 | actions.setSubmitting(false) 87 | }} 88 | onBack={async () => { 89 | await save() 90 | router.push('/form/district') 91 | }} 92 | currentPage="Student or Group Details" 93 | > 94 | 95 | 96 | Are you filing this complaint on behalf of one student or a group of 97 | students? 98 | 99 | { 102 | setStudentOrGroup(newValue as string) 103 | }} 104 | options={['One Student', 'Group of Students']} 105 | sx={{ width: 330, height: 42 }} 106 | renderInput={(params) => } 107 | /> 108 | 109 | 110 | Do any of the following apply to the student? 111 |
112 | {studentSpecialCircumstances.map((option, index) => { 113 | return ( 114 |
115 | handleOnChange(index)} 118 | /> 119 | 120 |
121 | ) 122 | })} 123 |
124 |
125 |
126 | ) 127 | } 128 | 129 | export default Group 130 | -------------------------------------------------------------------------------- /pages/form/concern.tsx: -------------------------------------------------------------------------------- 1 | import { useSession } from 'next-auth/react' 2 | import { useRouter } from 'next/router' 3 | import React, { useState, useEffect } from 'react' 4 | import styled from 'styled-components' 5 | import { FormTemplate } from '../../components/Critical/FormTemplate' 6 | import Tooltip from '../../components/DynamicForm/Tooltip' 7 | import { InfoText, TitleText } from '../../components/FormStyles/QuestionText' 8 | import { TextArea } from '../../components/FormStyles/TextArea' 9 | import { ConcernDB } from '../api/form/concern/save' 10 | import { InputCol } from './contactinfo' 11 | import isSignedIn from '../../utils/isSignedIn' 12 | 13 | const Concern: React.FC = () => { 14 | const [concern, setConcern] = useState(undefined) 15 | const router = useRouter() 16 | const { data, status } = useSession() 17 | const [loaded, setLoaded] = useState(false) 18 | 19 | const tooltipText = `This is the part where you're introducing the PRS officer to your concerns by telling them generally what happened. 20 | 21 | You don't have to be too specific here, because you'll add more information as we go through the questions. At the end of the walkthrough, you can review the whole statement of concerns and add anything that's missing. 22 | 23 | The information is specific to your situation and concerns, but here are a few examples of what you might write based on hypothetical situations: 24 | 25 | “The student was suspended from school but the school never told us exactly why. Now she has been out of school for two weeks and hasn't been able to make up any work. I'm concerned that she's falling behind, and the school won't help us figure out how she can make up her work.” 26 | 27 | “I asked the school to do a special education evaluation for the student in September and they still haven't done it. His grades have dropped and the school isn't giving him the support he needs.”` 28 | 29 | // reroutes to signup if not logged in 30 | if (!isSignedIn({ data, status })) { 31 | router.push('/signup') 32 | } 33 | 34 | // saves values to database 35 | const save = async () => { 36 | if (!data?.user?.id) { 37 | return 38 | } 39 | const body: Omit = { 40 | concern: concern, 41 | } 42 | const result = await fetch('/api/form/concern/save', { 43 | method: 'POST', 44 | body: JSON.stringify(body), 45 | }) 46 | const resBody = await result.json() 47 | if (result.status !== 200) { 48 | console.error(resBody.error) 49 | } 50 | } 51 | 52 | // loads values in from database, only loads once 53 | useEffect(() => { 54 | const retrieve = async () => { 55 | if (!data?.user?.id || loaded) { 56 | return 57 | } 58 | const result = await fetch(`/api/form/concern/retrieve`) 59 | const body = await result.json() 60 | if (result.status !== 200) { 61 | console.error(body.error) 62 | } else { 63 | const typedBody = body as ConcernDB 64 | setConcern(typedBody.concern) 65 | } 66 | setLoaded(true) 67 | } 68 | if (!loaded) { 69 | retrieve() 70 | } 71 | }, [data]) 72 | return ( 73 | { 78 | await save() 79 | router.push('/form') 80 | actions.setSubmitting(false) 81 | }} 82 | onBack={async () => { 83 | await save() 84 | router.push('/form/group') 85 | }} 86 | currentPage="Concerns" 87 | > 88 | 89 | 90 | Before we start the questions, please briefly describe your concerns 91 | in the box below. The text that you write will be the first paragraph 92 | of your complaint. It will set the stage for the more specific 93 | information that we get through the questions.{' '} 94 | 95 | 101 |