diff --git a/backend/src/app.ts b/backend/src/app.ts index 5d962d5..69b9fbf 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -6,6 +6,7 @@ import "dotenv/config"; import cors from "cors"; import express, { NextFunction, Request, Response } from "express"; import { isHttpError } from "http-errors"; +import vsrRoutes from "../src/routes/vsr"; import { userRouter } from "src/routes/users"; import env from "src/util/validateEnv"; @@ -52,4 +53,6 @@ app.use((error: unknown, req: Request, res: Response, next: NextFunction) => { res.status(statusCode).json({ error: errorMessage }); }); +app.use("/api/vsr", vsrRoutes); + export default app; diff --git a/backend/src/controllers/vsr.ts b/backend/src/controllers/vsr.ts new file mode 100644 index 0000000..641eb40 --- /dev/null +++ b/backend/src/controllers/vsr.ts @@ -0,0 +1,51 @@ +import { RequestHandler } from "express"; +import { validationResult } from "express-validator"; +import VSRModel from "src/models/vsr"; +import validationErrorParser from "src/util/validationErrorParser"; + +export const createVSR: RequestHandler = async (req, res, next) => { + // extract any errors that were found by the validator + const errors = validationResult(req); + const { + name, + gender, + age, + maritalStatus, + spouseName, + agesOfBoys, + agesOfGirls, + ethnicity, + employmentStatus, + incomeLevel, + sizeOfHome, + } = req.body; + + try { + // if there are errors, then this function throws an exception + validationErrorParser(errors); + + // Get the current date as a timestamp for when VSR was submitted + const date = new Date(); + + const vsr = await VSRModel.create({ + name, + date, + gender, + age, + maritalStatus, + spouseName, + agesOfBoys, + agesOfGirls, + ethnicity, + employmentStatus, + incomeLevel, + sizeOfHome, + }); + + // 201 means a new resource has been created successfully + // the newly created VSR is sent back to the user + res.status(201).json(vsr); + } catch (error) { + next(error); + } +}; diff --git a/backend/src/models/vsr.ts b/backend/src/models/vsr.ts new file mode 100644 index 0000000..8f5b551 --- /dev/null +++ b/backend/src/models/vsr.ts @@ -0,0 +1,20 @@ +import { InferSchemaType, Schema, model } from "mongoose"; + +const vsrSchema = new Schema({ + name: { type: String, required: true }, + date: { type: Date, required: true }, + gender: { type: String, require: true }, + age: { type: Number, require: true }, + maritalStatus: { type: String, required: true }, + spouseName: { type: String }, + agesOfBoys: { type: [Number] }, + agesOfGirls: { type: [Number] }, + ethnicity: { type: [String], require: true }, + employmentStatus: { type: String, require: true }, + incomeLevel: { type: String, require: true }, + sizeOfHome: { type: String, require: true }, +}); + +type VSR = InferSchemaType; + +export default model("VSR", vsrSchema); diff --git a/backend/src/routes/vsr.ts b/backend/src/routes/vsr.ts new file mode 100644 index 0000000..4a81cd4 --- /dev/null +++ b/backend/src/routes/vsr.ts @@ -0,0 +1,9 @@ +import express from "express"; +import * as VSRController from "src/controllers/vsr"; +import * as VSRValidator from "src/validators/vsr"; + +const router = express.Router(); + +router.post("/", VSRValidator.createVSR, VSRController.createVSR); + +export default router; diff --git a/backend/src/util/validationErrorParser.ts b/backend/src/util/validationErrorParser.ts new file mode 100644 index 0000000..844fe84 --- /dev/null +++ b/backend/src/util/validationErrorParser.ts @@ -0,0 +1,25 @@ +import { Result, ValidationError } from "express-validator"; +import createHttpError from "http-errors"; + +/** + * Parses through errors thrown by validator (if any exist). Error messages are + * added to a string and that string is used as the error message for the HTTP + * error. + * + * @param errors the validation result provided by express validator middleware + */ +const validationErrorParser = (errors: Result) => { + if (!errors.isEmpty()) { + let errorString = ""; + + // parse through errors returned by the validator and append them to the error string + for (const error of errors.array()) { + errorString += error.msg + " "; + } + + // trim removes the trailing space created in the for loop + throw createHttpError(400, errorString.trim()); + } +}; + +export default validationErrorParser; diff --git a/backend/src/validators/vsr.ts b/backend/src/validators/vsr.ts new file mode 100644 index 0000000..f57bc14 --- /dev/null +++ b/backend/src/validators/vsr.ts @@ -0,0 +1,97 @@ +import { body } from "express-validator"; + +const makeNameValidator = () => + body("name") + .exists({ checkFalsy: true }) + .withMessage("Name is required") + .isString() + .withMessage("Name must be a string"); + +const makeGenderValidator = () => + body("gender") + .exists({ checkFalsy: true }) + .withMessage("Gender is required") + .isString() + .withMessage("Gender must be a string"); + +const makeAgeValidator = () => + body("age") + .exists({ checkFalsy: true }) + .withMessage("Age is required") + .isInt({ min: 0 }) + .withMessage("Age must be a positive integer"); + +const makeMaritalStatusValidator = () => + body("maritalStatus") + .exists({ checkFalsy: true }) + .withMessage("Marital Status is required") + .isString() + .withMessage("Marital Status must be a string"); + +const makeSpouseNameValidator = () => + body("spouseName") + .optional({ checkFalsy: true }) + .isString() + .withMessage("Spouse Name must be a string"); + +const makeAgesOfBoysValidator = () => + body("agesOfBoys") + .exists({ checkFalsy: true }) + .isArray() + .withMessage("Ages of Boys must be an array of numbers") + .custom((ages: number[]) => ages.every((age) => Number.isInteger(age) && age >= 0)) + .withMessage("Each age in Ages of Boys must be a positive integer"); + +const makeAgesOfGirlsValidator = () => + body("agesOfGirls") + .exists({ checkFalsy: true }) + .isArray() + .withMessage("Ages of Girls must be an array of numbers") + .custom((ages: number[]) => ages.every((age) => Number.isInteger(age) && age >= 0)) + .withMessage("Each age in Ages of Girls must be a positive integer"); + +const makeEthnicityValidator = () => + body("ethnicity") + .exists({ checkFalsy: true }) + .withMessage("Ethnicity is required") + .isArray() + .withMessage("Ethnicity must be an array") + .custom((ethnicities: string[]) => + ethnicities.every((ethnicity) => typeof ethnicity === "string"), + ) + .withMessage("Each ethnicity in Ethnicities must be a positive integer"); + +const makeEmploymentStatusValidator = () => + body("employmentStatus") + .exists({ checkFalsy: true }) + .withMessage("Employment Status is required") + .isString() + .withMessage("Employment Status must be a string"); + +const makeIncomeLevelValidator = () => + body("incomeLevel") + .exists({ checkFalsy: true }) + .withMessage("Income Level is required") + .isString() + .withMessage("Income Level must be a string"); + +const makeSizeOfHomeValidator = () => + body("sizeOfHome") + .exists({ checkFalsy: true }) + .withMessage("Size of Home is required") + .isString() + .withMessage("Size of Home must be a string"); + +export const createVSR = [ + makeNameValidator(), + makeGenderValidator(), + makeAgeValidator(), + makeMaritalStatusValidator(), + makeSpouseNameValidator(), + makeAgesOfBoysValidator(), + makeAgesOfGirlsValidator(), + makeEthnicityValidator(), + makeEmploymentStatusValidator(), + makeIncomeLevelValidator(), + makeSizeOfHomeValidator(), +]; diff --git a/frontend/package-lock.json b/frontend/package-lock.json index f9db016..6d3b4e6 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -10,7 +10,9 @@ "dependencies": { "@emotion/react": "^11.11.3", "@emotion/styled": "^11.11.0", - "@mui/material": "^5.15.4", + "@material/textfield": "^14.0.0", + "@mui/material": "^5.15.6", + "@mui/styled-engine-sc": "^6.0.0-alpha.13", "@testing-library/jest-dom": "^6.1.4", "@testing-library/react": "^14.1.2", "envalid": "^8.0.0", @@ -20,6 +22,8 @@ "next": "14.0.3", "react": "^18", "react-dom": "^18", + "react-hook-form": "^7.49.3", + "styled-components": "^6.1.8", "ts-jest": "^29.1.1" }, "devDependencies": { @@ -1413,9 +1417,9 @@ } }, "node_modules/@floating-ui/react-dom": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.0.5.tgz", - "integrity": "sha512-UsBK30Bg+s6+nsgblXtZmwHhgS2vmbuQK22qgt2pTQM6M3X6H1+cQcLXqgRY3ihVLcZJE6IvqDQozhsnIVqK/Q==", + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.0.6.tgz", + "integrity": "sha512-IB8aCRFxr8nFkdYZgH+Otd9EVQPJoynxeFRGTB8voPoZMRWo8XjYuCRgpI1btvuKY69XMiLnW+ym7zoBHM90Rw==", "dependencies": { "@floating-ui/dom": "^1.5.4" }, @@ -1897,15 +1901,193 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@material/animation": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/@material/animation/-/animation-14.0.0.tgz", + "integrity": "sha512-VlYSfUaIj/BBVtRZI8Gv0VvzikFf+XgK0Zdgsok5c1v5DDnNz5tpB8mnGrveWz0rHbp1X4+CWLKrTwNmjrw3Xw==", + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/@material/base": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/@material/base/-/base-14.0.0.tgz", + "integrity": "sha512-Ou7vS7n1H4Y10MUZyYAbt6H0t67c6urxoCgeVT7M38aQlaNUwFMODp7KT/myjYz2YULfhu3PtfSV3Sltgac9mA==", + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/@material/density": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/@material/density/-/density-14.0.0.tgz", + "integrity": "sha512-NlxXBV5XjNsKd8UXF4K/+fOXLxoFNecKbsaQO6O2u+iG8QBfFreKRmkhEBb2hPPwC3w8nrODwXX0lHV+toICQw==", + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/@material/dom": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/@material/dom/-/dom-14.0.0.tgz", + "integrity": "sha512-8t88XyacclTj8qsIw9q0vEj4PI2KVncLoIsIMzwuMx49P2FZg6TsLjor262MI3Qs00UWAifuLMrhnOnfyrbe7Q==", + "dependencies": { + "@material/feature-targeting": "^14.0.0", + "tslib": "^2.1.0" + } + }, + "node_modules/@material/elevation": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/@material/elevation/-/elevation-14.0.0.tgz", + "integrity": "sha512-Di3tkxTpXwvf1GJUmaC8rd+zVh5dB2SWMBGagL4+kT8UmjSISif/OPRGuGnXs3QhF6nmEjkdC0ijdZLcYQkepw==", + "dependencies": { + "@material/animation": "^14.0.0", + "@material/base": "^14.0.0", + "@material/feature-targeting": "^14.0.0", + "@material/rtl": "^14.0.0", + "@material/theme": "^14.0.0", + "tslib": "^2.1.0" + } + }, + "node_modules/@material/feature-targeting": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/@material/feature-targeting/-/feature-targeting-14.0.0.tgz", + "integrity": "sha512-a5WGgHEq5lJeeNL5yevtgoZjBjXWy6+klfVWQEh8oyix/rMJygGgO7gEc52uv8fB8uAIoYEB3iBMOv8jRq8FeA==", + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/@material/floating-label": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/@material/floating-label/-/floating-label-14.0.0.tgz", + "integrity": "sha512-Aq8BboP1sbNnOtsV72AfaYirHyOrQ/GKFoLrZ1Jt+ZGIAuXPETcj9z7nQDznst0ZeKcz420PxNn9tsybTbeL/Q==", + "dependencies": { + "@material/animation": "^14.0.0", + "@material/base": "^14.0.0", + "@material/dom": "^14.0.0", + "@material/feature-targeting": "^14.0.0", + "@material/rtl": "^14.0.0", + "@material/theme": "^14.0.0", + "@material/typography": "^14.0.0", + "tslib": "^2.1.0" + } + }, + "node_modules/@material/line-ripple": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/@material/line-ripple/-/line-ripple-14.0.0.tgz", + "integrity": "sha512-Rx9eSnfp3FcsNz4O+fobNNq2PSm5tYHC3hRpY2ZK3ghTvgp3Y40/soaGEi/Vdg0F7jJXRaBSNOe6p5t9CVfy8Q==", + "dependencies": { + "@material/animation": "^14.0.0", + "@material/base": "^14.0.0", + "@material/feature-targeting": "^14.0.0", + "@material/theme": "^14.0.0", + "tslib": "^2.1.0" + } + }, + "node_modules/@material/notched-outline": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/@material/notched-outline/-/notched-outline-14.0.0.tgz", + "integrity": "sha512-6S58DlWmhCDr4RQF2RuwqANxlmLdHtWy2mF4JQLD9WOiCg4qY9eCQnMXu3Tbhr7f/nOZ0vzc7AtA3vfJoZmCSw==", + "dependencies": { + "@material/base": "^14.0.0", + "@material/feature-targeting": "^14.0.0", + "@material/floating-label": "^14.0.0", + "@material/rtl": "^14.0.0", + "@material/shape": "^14.0.0", + "@material/theme": "^14.0.0", + "tslib": "^2.1.0" + } + }, + "node_modules/@material/ripple": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/@material/ripple/-/ripple-14.0.0.tgz", + "integrity": "sha512-9XoGBFd5JhFgELgW7pqtiLy+CnCIcV2s9cQ2BWbOQeA8faX9UZIDUx/g76nHLZ7UzKFtsULJxZTwORmsEt2zvw==", + "dependencies": { + "@material/animation": "^14.0.0", + "@material/base": "^14.0.0", + "@material/dom": "^14.0.0", + "@material/feature-targeting": "^14.0.0", + "@material/rtl": "^14.0.0", + "@material/theme": "^14.0.0", + "tslib": "^2.1.0" + } + }, + "node_modules/@material/rtl": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/@material/rtl/-/rtl-14.0.0.tgz", + "integrity": "sha512-xl6OZYyRjuiW2hmbjV2omMV8sQtfmKAjeWnD1RMiAPLCTyOW9Lh/PYYnXjxUrNa0cRwIIbOn5J7OYXokja8puA==", + "dependencies": { + "@material/theme": "^14.0.0", + "tslib": "^2.1.0" + } + }, + "node_modules/@material/shape": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/@material/shape/-/shape-14.0.0.tgz", + "integrity": "sha512-o0mJB0+feOv473KckI8gFnUo8IQAaEA6ynXzw3VIYFjPi48pJwrxa0mZcJP/OoTXrCbDzDeFJfDPXEmRioBb9A==", + "dependencies": { + "@material/feature-targeting": "^14.0.0", + "@material/rtl": "^14.0.0", + "@material/theme": "^14.0.0", + "tslib": "^2.1.0" + } + }, + "node_modules/@material/textfield": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/@material/textfield/-/textfield-14.0.0.tgz", + "integrity": "sha512-HGbtAlvlIB2vWBq85yw5wQeeP3Kndl6Z0TJzQ6piVtcfdl2mPyWhuuVHQRRAOis3rCIaAAaxCQYYTJh8wIi0XQ==", + "dependencies": { + "@material/animation": "^14.0.0", + "@material/base": "^14.0.0", + "@material/density": "^14.0.0", + "@material/dom": "^14.0.0", + "@material/feature-targeting": "^14.0.0", + "@material/floating-label": "^14.0.0", + "@material/line-ripple": "^14.0.0", + "@material/notched-outline": "^14.0.0", + "@material/ripple": "^14.0.0", + "@material/rtl": "^14.0.0", + "@material/shape": "^14.0.0", + "@material/theme": "^14.0.0", + "@material/tokens": "^14.0.0", + "@material/typography": "^14.0.0", + "tslib": "^2.1.0" + } + }, + "node_modules/@material/theme": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/@material/theme/-/theme-14.0.0.tgz", + "integrity": "sha512-6/SENWNIFuXzeHMPHrYwbsXKgkvCtWuzzQ3cUu4UEt3KcQ5YpViazIM6h8ByYKZP8A9d8QpkJ0WGX5btGDcVoA==", + "dependencies": { + "@material/feature-targeting": "^14.0.0", + "tslib": "^2.1.0" + } + }, + "node_modules/@material/tokens": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/@material/tokens/-/tokens-14.0.0.tgz", + "integrity": "sha512-SXgB9VwsKW4DFkHmJfDIS0x0cGdMWC1D06m6z/WQQ5P5j6/m0pKrbHVlrLzXcRjau+mFhXGvj/KyPo9Pp/Rc8Q==", + "dependencies": { + "@material/elevation": "^14.0.0" + } + }, + "node_modules/@material/typography": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/@material/typography/-/typography-14.0.0.tgz", + "integrity": "sha512-/QtHBYiTR+TPMryM/CT386B2WlAQf/Ae32V324Z7P40gHLKY/YBXx7FDutAWZFeOerq/two4Nd2aAHBcMM2wMw==", + "dependencies": { + "@material/feature-targeting": "^14.0.0", + "@material/theme": "^14.0.0", + "tslib": "^2.1.0" + } + }, "node_modules/@mui/base": { - "version": "5.0.0-beta.31", - "resolved": "https://registry.npmjs.org/@mui/base/-/base-5.0.0-beta.31.tgz", - "integrity": "sha512-+uNbP3OHJuZVI00WyMg7xfLZotaEY7LgvYXDfONVJbrS+K9wyjCIPNfjy8r9XJn4fbHo/5ibiZqjWnU9LMNv+A==", + "version": "5.0.0-beta.33", + "resolved": "https://registry.npmjs.org/@mui/base/-/base-5.0.0-beta.33.tgz", + "integrity": "sha512-WcSpoJUw/UYHXpvgtl4HyMar2Ar97illUpqiS/X1gtSBp6sdDW6kB2BJ9OlVQ+Kk/RL2GDp/WHA9sbjAYV35ow==", "dependencies": { - "@babel/runtime": "^7.23.7", - "@floating-ui/react-dom": "^2.0.5", + "@babel/runtime": "^7.23.8", + "@floating-ui/react-dom": "^2.0.6", "@mui/types": "^7.2.13", - "@mui/utils": "^5.15.4", + "@mui/utils": "^5.15.6", "@popperjs/core": "^2.11.8", "clsx": "^2.1.0", "prop-types": "^15.8.1" @@ -1929,25 +2111,25 @@ } }, "node_modules/@mui/core-downloads-tracker": { - "version": "5.15.4", - "resolved": "https://registry.npmjs.org/@mui/core-downloads-tracker/-/core-downloads-tracker-5.15.4.tgz", - "integrity": "sha512-0OZN9O6hAtBpx70mMNFOPaAIol/ytwZYPY+z7Rf9dK3+1Xlzwvj5/IeShJKvtp76S1qJyhPuvZg0+BGqQaUnUw==", + "version": "5.15.6", + "resolved": "https://registry.npmjs.org/@mui/core-downloads-tracker/-/core-downloads-tracker-5.15.6.tgz", + "integrity": "sha512-0aoWS4qvk1uzm9JBs83oQmIMIQeTBUeqqu8u+3uo2tMznrB5fIKqQVCbCgq+4Tm4jG+5F7dIvnjvQ2aV7UKtdw==", "funding": { "type": "opencollective", "url": "https://opencollective.com/mui-org" } }, "node_modules/@mui/material": { - "version": "5.15.4", - "resolved": "https://registry.npmjs.org/@mui/material/-/material-5.15.4.tgz", - "integrity": "sha512-T/LGRAC+M0c+D3+y67eHwIN5bSje0TxbcJCWR0esNvU11T0QwrX3jedXItPNBwMupF2F5VWCDHBVLlFnN3+ABA==", - "dependencies": { - "@babel/runtime": "^7.23.7", - "@mui/base": "5.0.0-beta.31", - "@mui/core-downloads-tracker": "^5.15.4", - "@mui/system": "^5.15.4", + "version": "5.15.6", + "resolved": "https://registry.npmjs.org/@mui/material/-/material-5.15.6.tgz", + "integrity": "sha512-rw7bDdpi2kzfmcDN78lHp8swArJ5sBCKsn+4G3IpGfu44ycyWAWX0VdlvkjcR9Yrws2KIm7c+8niXpWHUDbWoA==", + "dependencies": { + "@babel/runtime": "^7.23.8", + "@mui/base": "5.0.0-beta.33", + "@mui/core-downloads-tracker": "^5.15.6", + "@mui/system": "^5.15.6", "@mui/types": "^7.2.13", - "@mui/utils": "^5.15.4", + "@mui/utils": "^5.15.6", "@types/react-transition-group": "^4.4.10", "clsx": "^2.1.0", "csstype": "^3.1.2", @@ -1987,12 +2169,12 @@ "integrity": "sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==" }, "node_modules/@mui/private-theming": { - "version": "5.15.4", - "resolved": "https://registry.npmjs.org/@mui/private-theming/-/private-theming-5.15.4.tgz", - "integrity": "sha512-9N5myIMEEQTM5WYWPGvvYADzjFo12LgJ7S+2iTZkBNOcJpUxQYM1tvYjkHCDV+t1ocMOEgjR2EfJ9Dus30dBlg==", + "version": "5.15.6", + "resolved": "https://registry.npmjs.org/@mui/private-theming/-/private-theming-5.15.6.tgz", + "integrity": "sha512-ZBX9E6VNUSscUOtU8uU462VvpvBS7eFl5VfxAzTRVQBHflzL+5KtnGrebgf6Nd6cdvxa1o0OomiaxSKoN2XDmg==", "dependencies": { - "@babel/runtime": "^7.23.7", - "@mui/utils": "^5.15.4", + "@babel/runtime": "^7.23.8", + "@mui/utils": "^5.15.6", "prop-types": "^15.8.1" }, "engines": { @@ -2013,11 +2195,11 @@ } }, "node_modules/@mui/styled-engine": { - "version": "5.15.4", - "resolved": "https://registry.npmjs.org/@mui/styled-engine/-/styled-engine-5.15.4.tgz", - "integrity": "sha512-vtrZUXG5XI8CNiNLcxjIirW4dEbOloR+ikfm6ePBo7jXpJdpXjVzBWetrfE+5eI0cHkKWlTptnJ2voKV8pBRfw==", + "version": "5.15.6", + "resolved": "https://registry.npmjs.org/@mui/styled-engine/-/styled-engine-5.15.6.tgz", + "integrity": "sha512-KAn8P8xP/WigFKMlEYUpU9z2o7jJnv0BG28Qu1dhNQVutsLVIFdRf5Nb+0ijp2qgtcmygQ0FtfRuXv5LYetZTg==", "dependencies": { - "@babel/runtime": "^7.23.7", + "@babel/runtime": "^7.23.8", "@emotion/cache": "^11.11.0", "csstype": "^3.1.2", "prop-types": "^15.8.1" @@ -2043,16 +2225,37 @@ } } }, + "node_modules/@mui/styled-engine-sc": { + "version": "6.0.0-alpha.13", + "resolved": "https://registry.npmjs.org/@mui/styled-engine-sc/-/styled-engine-sc-6.0.0-alpha.13.tgz", + "integrity": "sha512-QOv9oEZL3J/DYUJaQ6QFE7fTMxy/6J9mKKsdmQgHpgeESddiiCS0MCYoPsKoPcj6RABfzG12uqnq6o5s1seRLg==", + "dependencies": { + "@babel/runtime": "^7.23.8", + "csstype": "^3.1.2", + "hoist-non-react-statics": "^3.3.2", + "prop-types": "^15.8.1" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "styled-components": "^6.0.0" + } + }, "node_modules/@mui/system": { - "version": "5.15.4", - "resolved": "https://registry.npmjs.org/@mui/system/-/system-5.15.4.tgz", - "integrity": "sha512-KCwkHajGBXPs2TK1HJjIyab4NDk0cZoBDYN/TTlXVo1qBAmCjY0vjqrlsjeoG+wrwwcezXMLs/e6OGP66fPCog==", + "version": "5.15.6", + "resolved": "https://registry.npmjs.org/@mui/system/-/system-5.15.6.tgz", + "integrity": "sha512-J01D//u8IfXvaEHMBQX5aO2l7Q+P15nt96c4NskX7yp5/+UuZP8XCQJhtBtLuj+M2LLyXHYGmCPeblsmmscP2Q==", "dependencies": { - "@babel/runtime": "^7.23.7", - "@mui/private-theming": "^5.15.4", - "@mui/styled-engine": "^5.15.4", + "@babel/runtime": "^7.23.8", + "@mui/private-theming": "^5.15.6", + "@mui/styled-engine": "^5.15.6", "@mui/types": "^7.2.13", - "@mui/utils": "^5.15.4", + "@mui/utils": "^5.15.6", "clsx": "^2.1.0", "csstype": "^3.1.2", "prop-types": "^15.8.1" @@ -2096,11 +2299,11 @@ } }, "node_modules/@mui/utils": { - "version": "5.15.4", - "resolved": "https://registry.npmjs.org/@mui/utils/-/utils-5.15.4.tgz", - "integrity": "sha512-E2wLQGBcs3VR52CpMRjk46cGscC4cbf3Q2uyHNaAeL36yTTm+aVNbtsTCazXtjOP4BDd8lu6VtlTpVC8Rtl4mg==", + "version": "5.15.6", + "resolved": "https://registry.npmjs.org/@mui/utils/-/utils-5.15.6.tgz", + "integrity": "sha512-qfEhf+zfU9aQdbzo1qrSWlbPQhH1nCgeYgwhOVnj9Bn39shJQitEnXpSQpSNag8+uty5Od6PxmlNKPTnPySRKA==", "dependencies": { - "@babel/runtime": "^7.23.7", + "@babel/runtime": "^7.23.8", "@types/prop-types": "^15.7.11", "prop-types": "^15.8.1", "react-is": "^18.2.0" @@ -2704,6 +2907,11 @@ "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz", "integrity": "sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==" }, + "node_modules/@types/stylis": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@types/stylis/-/stylis-4.2.0.tgz", + "integrity": "sha512-n4sx2bqL0mW1tvDf/loQ+aMX7GQD3lc3fkCMC55VFNDu/vBOabO+LTIeXKM14xK0ppk5TUGcWRjiSpIlUpghKw==" + }, "node_modules/@types/tough-cookie": { "version": "4.0.5", "resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.5.tgz", @@ -3480,6 +3688,14 @@ "node": ">=6" } }, + "node_modules/camelize": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/camelize/-/camelize-1.0.1.tgz", + "integrity": "sha512-dU+Tx2fsypxTgtLoE36npi3UqcjSSMNYfkqgmoEhtZrraP5VWq0K7FkWVTYa8eMPtnU/G2txVsfdCJTn9uzpuQ==", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/caniuse-lite": { "version": "1.0.30001564", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001564.tgz", @@ -3666,6 +3882,24 @@ "node": ">= 8" } }, + "node_modules/css-color-keywords": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/css-color-keywords/-/css-color-keywords-1.0.0.tgz", + "integrity": "sha512-FyyrDHZKEjXDpNJYvVsV960FiqQyXc/LlYmsxl2BcdMb2WPx0OGRVgTg55rPSyLSNMqP52R9r8geSp7apN3Ofg==", + "engines": { + "node": ">=4" + } + }, + "node_modules/css-to-react-native": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/css-to-react-native/-/css-to-react-native-3.2.0.tgz", + "integrity": "sha512-e8RKaLXMOFii+02mOlqwjbD00KSEKqblnpO9e++1aXS1fPQOpS1YoqdVHBqPjHNoxeF2mimzVqawm2KCbEdtHQ==", + "dependencies": { + "camelize": "^1.0.0", + "css-color-keywords": "^1.0.0", + "postcss-value-parser": "^4.0.2" + } + }, "node_modules/css.escape": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", @@ -7249,6 +7483,11 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==" + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -7423,6 +7662,22 @@ "react": "^18.2.0" } }, + "node_modules/react-hook-form": { + "version": "7.49.3", + "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.49.3.tgz", + "integrity": "sha512-foD6r3juidAT1cOZzpmD/gOKt7fRsDhXXZ0y28+Al1CHgX+AY1qIN9VSIIItXRq1dN68QrRwl1ORFlwjBaAqeQ==", + "engines": { + "node": ">=18", + "pnpm": "8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/react-hook-form" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17 || ^18" + } + }, "node_modules/react-is": { "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", @@ -7733,6 +7988,11 @@ "node": ">= 0.4" } }, + "node_modules/shallowequal": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/shallowequal/-/shallowequal-1.1.0.tgz", + "integrity": "sha512-y0m1JoUZSlPAjXVtPPW70aZWfIL/dSP7AFkRnniLCrK/8MDKog3TySTBmckD+RObVxH0v4Tox67+F14PdED2oQ==" + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -7996,6 +8256,48 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/styled-components": { + "version": "6.1.8", + "resolved": "https://registry.npmjs.org/styled-components/-/styled-components-6.1.8.tgz", + "integrity": "sha512-PQ6Dn+QxlWyEGCKDS71NGsXoVLKfE1c3vApkvDYS5KAK+V8fNWGhbSUEo9Gg2iaID2tjLXegEW3bZDUGpofRWw==", + "dependencies": { + "@emotion/is-prop-valid": "1.2.1", + "@emotion/unitless": "0.8.0", + "@types/stylis": "4.2.0", + "css-to-react-native": "3.2.0", + "csstype": "3.1.2", + "postcss": "8.4.31", + "shallowequal": "1.1.0", + "stylis": "4.3.1", + "tslib": "2.5.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/styled-components" + }, + "peerDependencies": { + "react": ">= 16.8.0", + "react-dom": ">= 16.8.0" + } + }, + "node_modules/styled-components/node_modules/@emotion/unitless": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.8.0.tgz", + "integrity": "sha512-VINS5vEYAscRl2ZUDiT3uMPlrFQupiKgHz5AA4bCH1miKBg4qtwkim1qPmJj/4WG6TreYMY111rEFsjupcOKHw==" + }, + "node_modules/styled-components/node_modules/stylis": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.3.1.tgz", + "integrity": "sha512-EQepAV+wMsIaGVGX1RECzgrcqRRU/0sYOHkeLsZ3fzHaHXZy4DaOOX0vOlGQdlsjkh3mFHAIlVimpwAs4dslyQ==" + }, + "node_modules/styled-components/node_modules/tslib": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.5.0.tgz", + "integrity": "sha512-336iVw3rtn2BUK7ORdIAHTyxHGRIHVReokCR3XjbckJMK7ms8FysBfhLR8IXnAgy7T0PTPNBWKiH514FOW/WSg==" + }, "node_modules/styled-jsx": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index 558af1e..ec43e25 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -16,7 +16,9 @@ "dependencies": { "@emotion/react": "^11.11.3", "@emotion/styled": "^11.11.0", - "@mui/material": "^5.15.4", + "@material/textfield": "^14.0.0", + "@mui/material": "^5.15.6", + "@mui/styled-engine-sc": "^6.0.0-alpha.13", "@testing-library/jest-dom": "^6.1.4", "@testing-library/react": "^14.1.2", "envalid": "^8.0.0", @@ -26,6 +28,8 @@ "next": "14.0.3", "react": "^18", "react-dom": "^18", + "react-hook-form": "^7.49.3", + "styled-components": "^6.1.8", "ts-jest": "^29.1.1" }, "devDependencies": { diff --git a/frontend/public/logo.svg b/frontend/public/logo.svg new file mode 100644 index 0000000..433e0ee --- /dev/null +++ b/frontend/public/logo.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/frontend/public/next.svg b/frontend/public/next.svg deleted file mode 100644 index 5174b28..0000000 --- a/frontend/public/next.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/frontend/public/vercel.svg b/frontend/public/vercel.svg deleted file mode 100644 index d2f8422..0000000 --- a/frontend/public/vercel.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/frontend/src/api/VSRs.ts b/frontend/src/api/VSRs.ts new file mode 100644 index 0000000..ea672e3 --- /dev/null +++ b/frontend/src/api/VSRs.ts @@ -0,0 +1,75 @@ +import { APIResult, handleAPIError, post } from "@/api/requests"; + +export interface VSRJson { + _id: string; + name: string; + date: Date; + gender: string; + age: number; + maritalStatus: string; + spouseName?: string; + agesOfBoys: number[]; + agesOfGirls: number[]; + ethnicity: string[]; + employmentStatus: string; + incomeLevel: string; + sizeOfHome: string; +} + +export interface VSR { + _id: string; + name: string; + date: string; + gender: string; + age: number; + maritalStatus: string; + spouseName?: string; + agesOfBoys: number[]; + agesOfGirls: number[]; + ethnicity: string[]; + employmentStatus: string; + incomeLevel: string; + sizeOfHome: string; +} + +export interface CreateVSRRequest { + name: string; + gender: string; + age: number; + maritalStatus: string; + spouseName?: string; + agesOfBoys: number[]; + agesOfGirls: number[]; + ethnicity: string[]; + employmentStatus: string; + incomeLevel: string; + sizeOfHome: string; +} + +function parseVSR(vsr: VSRJson) { + return { + _id: vsr._id, + name: vsr.name, + date: new Date(vsr.date).toISOString(), + gender: vsr.gender, + age: vsr.age, + maritalStatus: vsr.maritalStatus, + spouseName: vsr.spouseName, + agesOfBoys: vsr.agesOfBoys, + agesOfGirls: vsr.agesOfGirls, + ethnicity: vsr.ethnicity, + employmentStatus: vsr.employmentStatus, + incomeLevel: vsr.incomeLevel, + sizeOfHome: vsr.sizeOfHome, + }; +} + +export async function createVSR(vsr: CreateVSRRequest): Promise> { + try { + const response = await post("/api/vsr", vsr); + const json = (await response.json()) as VSRJson; + return { success: true, data: parseVSR(json) }; + } catch (error) { + return handleAPIError(error); + } +} diff --git a/frontend/src/api/requests.ts b/frontend/src/api/requests.ts new file mode 100644 index 0000000..bc0883d --- /dev/null +++ b/frontend/src/api/requests.ts @@ -0,0 +1,164 @@ +import env from "@/util/validateEnv"; + +const API_BASE_URL = env.NEXT_PUBLIC_BACKEND_URL; +type Method = "GET" | "POST" | "PUT"; + +/** + * A wrapper around the built-in `fetch()` function that abstracts away some of + * the low-level details so we can focus on the important parts of each request. + * See https://developer.mozilla.org/en-US/docs/Web/API/fetch for information + * about the Fetch API. + * + * @param method The HTTP method to use + * @param url The URL to request + * @param body The body of the request, or undefined if there is none + * @param headers The headers of the request + * @returns The Response object returned by `fetch() + */ +async function fetchRequest( + method: Method, + url: string, + body: unknown, + headers: Record, +): Promise { + const hasBody = body !== undefined; + + const newHeaders = { ...headers }; + if (hasBody) { + newHeaders["Content-Type"] = "application/json"; + } + + const response = await fetch(url, { + method, + headers: newHeaders, + body: hasBody ? JSON.stringify(body) : undefined, + }); + + return response; +} + +/** + * Throws an error if the given response's status code indicates an error + * occurred, else does nothing. + * + * @param response A response returned by `fetch()` or `fetchRequest()` + * @throws An error if the response was not successful (200-299) or a redirect + * (300-399) + */ +async function assertOk(response: Response): Promise { + if (response.ok) { + return; + } + + let message = `${response.status} ${response.statusText}`; + + try { + const text = await response.text(); + if (text) { + message += ": " + text; + } + } catch (e) { + // skip errors + } + + throw new Error(message); +} + +/** + * Sends a GET request to the provided API URL. + * + * @param url The URL to request + * @param headers The headers of the request (optional) + * @returns The Response object returned by `fetch()` + */ +export async function get(url: string, headers: Record = {}): Promise { + // GET requests do not have a body + const response = await fetchRequest("GET", API_BASE_URL + url, undefined, headers); + assertOk(response); + return response; +} + +/** + * Sends a POST request to the provided API URL. + * + * @param url The URL to request + * @param body The body of the request, or undefined if there is none + * @param headers The headers of the request (optional) + * @returns The Response object returned by `fetch()` + */ +export async function post( + url: string, + body: unknown, + headers: Record = {}, +): Promise { + const response = await fetchRequest("POST", API_BASE_URL + url, body, headers); + assertOk(response); + return response; +} + +/** + * Sends a PUT request to the provided API URL. + * + * @param url The URL to request + * @param body The body of the request, or undefined if there is none + * @param headers The headers of the request (optional) + * @returns The Response object returned by `fetch()` + */ +export async function put( + url: string, + body: unknown, + headers: Record = {}, +): Promise { + const response = await fetchRequest("PUT", API_BASE_URL + url, body, headers); + assertOk(response); + return response; +} + +export type APIData = { success: true; data: T }; +export type APIError = { success: false; error: string }; +/** + * Utility type for the result of an API request. API client functions should + * always return an object of this type (without throwing an exception if + * something goes wrong). This allows users of the functions to perform easier + * error checking without excessive try-catch statements, making use of + * TypeScript's type narrowing feature. Specifically, by checking whether the + * `success` field is true or false, you'll know whether you can access the + * `data` field with the actual API response or the `error` field with an error + * message. + * + * For example, assume we have some API function with the type definition + * `doSomeRequest: (parameters: SomeParameters) => Promise>`. + * Then we could use it in a frontend component as follows: + * ``` + * doSomeRequest(parameters).then((result: APIResult) => { + * if (result.success) { + * console.log(result.data); // do something with the data, which is of type SomeData + * } else { + * console.error(result.error); // do something to inform the user of the error + * } + * }) + * ``` + * + * See `createTask` in `src/api/tasks` and its use in `src/components/TaskForm` + * for a more concrete example, and see + * https://www.typescriptlang.org/docs/handbook/2/narrowing.html for more info + * about type narrowing. + */ +export type APIResult = APIData | APIError; + +/** + * Helper function for API client functions to handle errors consistently. + * Recommended usage is in a `catch` block--see `createTask` in `src/api/tasks` + * for an example. + * + * @param error An error thrown by a lower-level API function + * @returns An `APIError` object with a message from the given error + */ +export function handleAPIError(error: unknown): APIError { + if (error instanceof Error) { + return { success: false, error: error.message }; + } else if (typeof error === "string") { + return { success: false, error }; + } + return { success: false, error: `Unknown error: ${String(error)}` }; +} diff --git a/frontend/src/app/dummyPage/layout.tsx b/frontend/src/app/dummyPage/layout.tsx index 1914d33..5ffb94e 100644 --- a/frontend/src/app/dummyPage/layout.tsx +++ b/frontend/src/app/dummyPage/layout.tsx @@ -1,6 +1,6 @@ export const metadata = { - title: "Next.js", - description: "Generated by Next.js", + title: "Patriots & Paws", + description: "Web application for Patriots & Paws", }; export default function DummyLayout({ children }: { children: React.ReactNode }) { diff --git a/frontend/src/app/favicon.ico b/frontend/src/app/favicon.ico index 718d6fe..b643ed9 100644 Binary files a/frontend/src/app/favicon.ico and b/frontend/src/app/favicon.ico differ diff --git a/frontend/src/app/layout.tsx b/frontend/src/app/layout.tsx index 4b0ee04..9bdea3d 100644 --- a/frontend/src/app/layout.tsx +++ b/frontend/src/app/layout.tsx @@ -1,4 +1,5 @@ import { Inter } from "next/font/google"; +import React from "react"; import type { Metadata } from "next"; import "@/app/globals.css"; @@ -6,8 +7,8 @@ import "@/app/globals.css"; const inter = Inter({ subsets: ["latin"] }); export const metadata: Metadata = { - title: "Create Next App", - description: "Generated by create next app", + title: "Patriots & Paws", + description: "Web application for Patriots & Paws", }; export default function RootLayout({ children }: { children: React.ReactNode }) { diff --git a/frontend/src/app/login/layout.tsx b/frontend/src/app/login/layout.tsx index 05464f6..cd37163 100644 --- a/frontend/src/app/login/layout.tsx +++ b/frontend/src/app/login/layout.tsx @@ -1,6 +1,6 @@ export const metadata = { - title: "Next.js", - description: "Generated by Next.js", + title: "Patriots & Paws", + description: "Web application for Patriots & Paws", }; export default function LoginLayout({ children }: { children: React.ReactNode }) { diff --git a/frontend/src/app/page.module.css b/frontend/src/app/page.module.css index 61e0547..e69de29 100644 --- a/frontend/src/app/page.module.css +++ b/frontend/src/app/page.module.css @@ -1,227 +0,0 @@ -.main { - display: flex; - flex-direction: column; - justify-content: space-between; - align-items: center; - padding: 6rem; - min-height: 100vh; -} - -.description { - display: inherit; - justify-content: inherit; - align-items: inherit; - font-size: 0.85rem; - max-width: var(--max-width); - width: 100%; - z-index: 2; - font-family: var(--font-mono); -} - -.description a { - display: flex; - justify-content: center; - align-items: center; - gap: 0.5rem; -} - -.description p { - position: relative; - margin: 0; - padding: 1rem; - background-color: rgba(var(--callout-rgb), 0.5); - border: 1px solid rgba(var(--callout-border-rgb), 0.3); - border-radius: var(--border-radius); -} - -.code { - font-weight: 700; - font-family: var(--font-mono); -} - -.grid { - display: grid; - grid-template-columns: repeat(4, minmax(25%, auto)); - max-width: 100%; - width: var(--max-width); -} - -.card { - padding: 1rem 1.2rem; - border-radius: var(--border-radius); - background: rgba(var(--card-rgb), 0); - border: 1px solid rgba(var(--card-border-rgb), 0); - transition: - background 200ms, - border 200ms; -} - -.card span { - display: inline-block; - transition: transform 200ms; -} - -.card h2 { - font-weight: 600; - margin-bottom: 0.7rem; -} - -.card p { - margin: 0; - opacity: 0.6; - font-size: 0.9rem; - line-height: 1.5; - max-width: 30ch; -} - -.center { - display: flex; - justify-content: center; - align-items: center; - position: relative; - padding: 4rem 0; -} - -.center::before { - background: var(--secondary-glow); - border-radius: 50%; - width: 480px; - height: 360px; - margin-left: -400px; -} - -.center::after { - background: var(--primary-glow); - width: 240px; - height: 180px; - z-index: -1; -} - -.center::before, -.center::after { - content: ""; - left: 50%; - position: absolute; - filter: blur(45px); - transform: translateZ(0); -} - -.logo { - position: relative; -} -/* Enable hover only on non-touch devices */ -@media (hover: hover) and (pointer: fine) { - .card:hover { - background: rgba(var(--card-rgb), 0.1); - border: 1px solid rgba(var(--card-border-rgb), 0.15); - } - - .card:hover span { - transform: translateX(4px); - } -} - -@media (prefers-reduced-motion) { - .card:hover span { - transform: none; - } -} - -/* Mobile */ -@media (max-width: 700px) { - .content { - padding: 4rem; - } - - .grid { - grid-template-columns: 1fr; - margin-bottom: 120px; - max-width: 320px; - text-align: center; - } - - .card { - padding: 1rem 2.5rem; - } - - .card h2 { - margin-bottom: 0.5rem; - } - - .center { - padding: 8rem 0 6rem; - } - - .center::before { - transform: none; - height: 300px; - } - - .description { - font-size: 0.8rem; - } - - .description a { - padding: 1rem; - } - - .description p, - .description div { - display: flex; - justify-content: center; - position: fixed; - width: 100%; - } - - .description p { - align-items: center; - inset: 0 0 auto; - padding: 2rem 1rem 1.4rem; - border-radius: 0; - border: none; - border-bottom: 1px solid rgba(var(--callout-border-rgb), 0.25); - background: linear-gradient( - to bottom, - rgba(var(--background-start-rgb), 1), - rgba(var(--callout-rgb), 0.5) - ); - background-clip: padding-box; - backdrop-filter: blur(24px); - } - - .description div { - align-items: flex-end; - pointer-events: none; - inset: auto 0 0; - padding: 2rem; - height: 200px; - background: linear-gradient(to bottom, transparent 0%, rgb(var(--background-end-rgb)) 40%); - z-index: 1; - } -} - -/* Tablet and Smaller Desktop */ -@media (min-width: 701px) and (max-width: 1120px) { - .grid { - grid-template-columns: repeat(2, 50%); - } -} - -@media (prefers-color-scheme: dark) { - .vercelLogo { - filter: invert(1); - } - - .logo { - filter: invert(1) drop-shadow(0 0 0.3rem #ffffff70); - } -} - -@keyframes rotate { - from { - transform: rotate(360deg); - } - to { - transform: rotate(0deg); - } -} diff --git a/frontend/src/app/vsr/page.module.css b/frontend/src/app/vsr/page.module.css new file mode 100644 index 0000000..df1bafd --- /dev/null +++ b/frontend/src/app/vsr/page.module.css @@ -0,0 +1,129 @@ +.main { + padding: 64px; + padding-top: 128px; + background-color: #eceff3; + display: flex; + flex-direction: column; + color: var(--Accent-Blue-1, #102d5f); + text-align: left; + gap: 32px; + /* Desktop/H1 */ + font-family: Lora; + font-size: 20px; + font-style: normal; + font-weight: 700; + line-height: normal; +} + +.description { + color: #000; + /* Desktop/Body 1 */ + font-family: "Open Sans"; + font-size: 20px; + font-style: normal; + font-weight: 400; + line-height: normal; +} + +.personalInfo { + color: var(--Accent-Blue-1, #102d5f); + /* Desktop/H2 */ + font-family: Lora; + font-size: 24px; + font-style: normal; + font-weight: 700; + line-height: normal; +} + +.fieldsMarked { + color: #000; + font-size: 16px; + font-style: normal; + font-weight: 400; +} + +.formContainer { + display: flex; + background-color: white; + border-radius: 20px; + padding: 64px; + margin-top: 32px; + gap: 10px; + align-items: flex-start; + width: 100%; + height: 100%; +} + +.form { + display: flex; + flex-direction: column; + gap: 64px; + align-items: flex-start; + width: 100%; +} + +.subSec { + display: flex; + width: 100%; + flex-direction: column; + gap: 32px; + align-self: flex-start; +} + +.formRow { + width: 100%; + display: flex; + flex-direction: row; + gap: 64px; + align-items: start; +} + +.numChildren { + display: flex; + flex-direction: column; + gap: 20px; + align-items: center; + flex: 1; +} + +.asterisk { + color: var(--Secondary-2, #be2d46); +} + +.submitButton { + align-self: end; +} + +.submit { + width: 306px; + height: 64px; + background-color: #102d5f; + color: white; + cursor: pointer; + font-family: Lora; + font-size: 24px; + font-style: normal; + font-weight: 700; + line-height: normal; + border: none; + border-radius: 4px; +} + +.sectionHeader { + gap: 4px; + font-size: 16px; + color: black; + font-family: "Open Sans"; + font-style: normal; + font-weight: 400; + line-height: normal; + margin: 16px 0 -8px; +} + +.longText { + flex: 1; +} + +.childInputWrapper { + width: 100%; +} diff --git a/frontend/src/app/vsr/page.tsx b/frontend/src/app/vsr/page.tsx new file mode 100644 index 0000000..83e9b02 --- /dev/null +++ b/frontend/src/app/vsr/page.tsx @@ -0,0 +1,417 @@ +"use client"; +import React, { useState } from "react"; +import styles from "src/app/vsr/page.module.css"; +import { useForm, Controller, SubmitHandler } from "react-hook-form"; +import TextField from "@/components/TextField"; +import MultipleChoice from "@/components/MultipleChoice"; +import Dropdown from "@/components/Dropdown"; +import HeaderBar from "@/components/HeaderBar"; +import PageNumber from "@/components/PageNumber"; +import { createVSR, CreateVSRRequest } from "@/api/VSRs"; + +interface IFormInput { + name: string; + marital_status: string; + gender: string; + spouse: string; + age: number; + ethnicity: string; + other_ethnicity: string; + employment_status: string; + income_level: string; + size_of_home: string; + num_boys: number; + num_girls: number; + ages_of_boys: number[]; + ages_of_girls: number[]; +} + +const VeteranServiceRequest: React.FC = () => { + const { + register, + handleSubmit, + control, + formState: { errors }, + watch, + } = useForm(); + const [selectedEthnicities, setSelectedEthnicities] = useState([]); + const [otherEthnicity, setOtherEthnicity] = useState(""); + + const numBoys = watch("num_boys"); + const numGirls = watch("num_girls"); + + const maritalOptions = ["Married", "Single", "It's Complicated", "Widowed/Widower"]; + const genderOptions = ["Male", "Female", "Other"]; + const employmentOptions = [ + "Employed", + "Unemployed", + "Currently Looking", + "Retired", + "In School", + "Unable to work", + ]; + + const incomeOptions = [ + "$12,500 and under", + "$12,501 - $25,000", + "$25,001 - $50,000", + "$50,001 and over", + ]; + + const homeOptions = [ + "House", + "Apartment", + "Studio", + "1 Bedroom", + "2 Bedroom", + "3 Bedroom", + "4 Bedroom", + "4+ Bedroom", + ]; + + const ethnicityOptions = [ + "Asian", + "African American", + "Caucasian", + "Native American", + "Pacific Islander", + "Middle Eastern", + "Prefer not to say", + ]; + + const onSubmit: SubmitHandler = async (data) => { + // Construct the request object + const createVSRRequest: CreateVSRRequest = { + name: data.name, + gender: data.gender, + age: data.age, + maritalStatus: data.marital_status, + spouseName: data.spouse, + agesOfBoys: + data.ages_of_boys + ?.slice(0, data.num_boys) + .map((age) => (typeof age === "number" ? age : parseInt(age))) ?? [], + agesOfGirls: + data.ages_of_girls + ?.slice(0, data.num_girls) + .map((age) => (typeof age === "number" ? age : parseInt(age))) ?? [], + ethnicity: selectedEthnicities.concat(otherEthnicity === "" ? [] : [otherEthnicity]), + employmentStatus: data.employment_status, + incomeLevel: data.income_level, + sizeOfHome: data.size_of_home, + }; + + try { + const response = await createVSR(createVSRRequest); + + if (!response.success) { + // TODO: better way of displaying error + throw new Error(`HTTP error! status: ${response.error}`); + } + + // TODO: better way of displaying successful submission (popup/modal) + alert("VSR submitted successfully!"); + } catch (error) { + console.error("There was a problem with the fetch operation:", error); + } + }; + + const renderChildInput = (gender: "boy" | "girl") => { + const numChildrenThisGender = gender === "boy" ? numBoys : numGirls; + + return ( + <> +
+ +
+ +
+ {/* Cap it at 99 children per gender to avoid freezing web browser */} + {Array.from({ length: Math.min(numChildrenThisGender, 99) }, (_, index) => ( +
+ +
+ ))} +
+ + ); + }; + + return ( +
+
+ +
+

Veteran Service Request Form

+

+ Welcome, Veterans, Active Duty, and Reservists. We invite you to schedule an appointment + to explore a selection of household furnishings and essential items available in our + warehouse. +

+

+ Let us know your specific needs, and we'll provide the best assistance possible. + Expect a response within 48 business hours; remember to check your junk mail if needed. +

+

+ If you're a Veteran or Active Military Reservist in search of our services, simply + fill out and submit the form below. +

+ +
+

+ Fields marked with * are required. +

+
+ +
+
+
+

Personal Information

+ +
+
+ +
+ {/* Add an empty div here with flex: 1 to take up the right half of the row */} +
+
+ +
+
+ ( + field.onChange(e)} + required + error={!!errors.gender} + helperText={errors.gender?.message} + placeholder="Select your gender" + /> + )} + /> +
+
+ +
+
+
+ +
+ ( + field.onChange(newValue)} + required + error={!!errors.marital_status} + helperText={errors.marital_status?.message} + /> + )} + /> + {watch().marital_status === "Married" ? ( +
+
+ +
+ {/* Add an empty div here with flex: 1 to take up the right half of the row */} +
+
+ ) : null} + +

Children (under 18)

+ +
+ {renderChildInput("boy")} + {renderChildInput("girl")} +
+
+ + ( + <> + { + const valueToSet = ((newValue as string[]) ?? [])[0] ?? ""; + if (valueToSet !== "" || otherEthnicity === "") { + field.onChange(valueToSet); + } + setSelectedEthnicities(newValue as string[]); + }} + required + error={!!errors.ethnicity} + helperText={errors.ethnicity?.message} + allowMultiple + /> +
+ { + const value = e.target.value; + if (value !== "" || selectedEthnicities.length === 0) { + field.onChange(value); + } + setOtherEthnicity(value); + }} + variant={"outlined"} + required={false} + /> +
+ + )} + /> + + ( + field.onChange(newValue)} + required + error={!!errors.employment_status} + helperText={errors.employment_status?.message} + /> + )} + /> + + ( + field.onChange(newValue)} + required + error={!!errors.income_level} + helperText={errors.income_level?.message} + /> + )} + /> + + ( + field.onChange(newValue)} + required + error={!!errors.size_of_home} + helperText={errors.size_of_home?.message} + /> + )} + /> +
+
+
+ +
+ +
+ +
+ ); +}; + +export default VeteranServiceRequest; diff --git a/frontend/src/components/Dropdown.module.css b/frontend/src/components/Dropdown.module.css new file mode 100644 index 0000000..dfd998b --- /dev/null +++ b/frontend/src/components/Dropdown.module.css @@ -0,0 +1,47 @@ +.wrapperClass { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 4px; + flex: 1 0 0; + font-size: 16px; + color: var(--Light-Gray, #818181); + font-family: "Open Sans"; + font-style: normal; + font-weight: 400; + line-height: normal; +} + +.dropDown { + color: var(--Dark-Gray, #484848); + font-family: "Open Sans"; + font-size: 16px; + font-style: italic; + font-weight: 300; + line-height: normal; + align-items: flex-start; + gap: 10px; + align-self: stretch; +} + +.form { + width: 100%; + height: 100%; +} + +.requiredAsterisk { + color: var(--Secondary-2, #be2d46); + text-align: left; +} + +.helperText { + color: var(--Secondary-2, #be2d46); + font-size: 12px; +} + +.placeholder { + font-size: 16px; + font-style: italic; + font-weight: 300; + color: var(--Dark-Gray, #484848); +} diff --git a/frontend/src/components/Dropdown.tsx b/frontend/src/components/Dropdown.tsx new file mode 100644 index 0000000..e9f1204 --- /dev/null +++ b/frontend/src/components/Dropdown.tsx @@ -0,0 +1,59 @@ +import React from "react"; +import styles from "src/components/Dropdown.module.css"; +import { FormControl, Select, MenuItem, SelectChangeEvent } from "@mui/material"; + +export interface DropDownProps { + label: string; + options: string[]; + value: string; + onChange: (event: SelectChangeEvent) => void; + required: boolean; + error?: boolean; + helperText?: string; + placeholder?: string; +} + +const Dropdown = ({ + label, + options, + value, + onChange, + required, + error, + helperText, + placeholder, +}: DropDownProps) => { + return ( +
+

+ {required ? * : null} + {label} +

+ + + + {helperText ?
{helperText}
: null} +
+ ); +}; + +export default Dropdown; diff --git a/frontend/src/components/HeaderBar.module.css b/frontend/src/components/HeaderBar.module.css new file mode 100644 index 0000000..c71779b --- /dev/null +++ b/frontend/src/components/HeaderBar.module.css @@ -0,0 +1,14 @@ +.headerBar { + position: sticky; + top: 0; + left: 0; + width: 100%; + height: 101px; + background-color: #fff; + border-bottom: 1px solid rgba(0, 0, 0, 0.25); +} + +.logo { + margin-top: 27px; + margin-left: 63px; +} diff --git a/frontend/src/components/HeaderBar.tsx b/frontend/src/components/HeaderBar.tsx new file mode 100644 index 0000000..843f93d --- /dev/null +++ b/frontend/src/components/HeaderBar.tsx @@ -0,0 +1,13 @@ +import Image from "next/image"; +import React from "react"; +import styles from "src/components/HeaderBar.module.css"; + +const HeaderBar = () => { + return ( +
+ logo +
+ ); +}; + +export default HeaderBar; diff --git a/frontend/src/components/MultipleChoice.module.css b/frontend/src/components/MultipleChoice.module.css new file mode 100644 index 0000000..b3a1f93 --- /dev/null +++ b/frontend/src/components/MultipleChoice.module.css @@ -0,0 +1,64 @@ +/* MultipleChoice.module.css */ + +.wrapperClass { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 16px; + flex: 1 0 0; + font-size: 16px; + color: var(--Light-Gray, #818181); + font-family: "Open Sans"; + font-style: normal; + font-weight: 400; + line-height: normal; +} + +.chip { + border-width: 1px; + border-style: solid; + text-align: center; + font-family: "Open Sans"; + font-size: 16px; + font-style: normal; + font-weight: 400; + line-height: normal; + border-radius: 64px; + border: 1px solid var(--Secondary-1, #102d5f); +} + +.chipSelected { + color: white; + background: #102d5f; +} + +.chipSelected:hover { + background: #102d5f; +} + +.chipContainer { + display: flex; + flex-direction: row; + gap: 16px; + flex-wrap: wrap; +} + +.chipUnselected { + color: var(--Accent-Blue-1, #102d5f); + background: rgba(255, 255, 255, 0); +} + +/*Need exact color for this*/ +.chipUnselected:hover { + background: rgb(213, 232, 239); +} + +.requiredAsterisk { + color: var(--Secondary-2, #be2d46); + text-align: left; +} + +.helperText { + color: var(--Secondary-2, #be2d46); + font-size: 12px; +} diff --git a/frontend/src/components/MultipleChoice.tsx b/frontend/src/components/MultipleChoice.tsx new file mode 100644 index 0000000..a9f8591 --- /dev/null +++ b/frontend/src/components/MultipleChoice.tsx @@ -0,0 +1,70 @@ +import Chip from "@mui/material/Chip"; +import styles from "@/components/MultipleChoice.module.css"; + +export interface MultipleChoiceProps { + label: string; + options: string[]; + value: string | string[]; + onChange: (selected: string | string[]) => void; + required: boolean; + allowMultiple?: boolean; + error?: boolean; + helperText?: string; +} + +const MultipleChoice = ({ + label, + options, + value, + onChange, + required, + allowMultiple = false, + helperText, +}: MultipleChoiceProps) => { + return ( +
+

+ {required ? * : null} + {label} +

+
+ {options.map((option) => { + const optionIsSelected = allowMultiple ? value?.includes(option) : value === option; + + return ( + { + if (allowMultiple) { + if (optionIsSelected) { + // Allow multiple + already selected -> remove option from selected + onChange(((value as string[]) ?? []).filter((_value) => _value !== option)); + } else { + // Allow multiple + not already selected -> add option to selected + onChange(((value as string[]) ?? []).concat([option])); + } + } else { + if (optionIsSelected) { + // Disallow multiple + already selected -> set value to nothing selected + onChange(""); + } else { + // Disallow multiple + not already selected -> set value to option + onChange(option); + } + } + }} + className={`${styles.chip} ${ + optionIsSelected ? styles.chipSelected : styles.chipUnselected + }`} + clickable + /> + ); + })} +
+ {helperText ?
{helperText}
: null} +
+ ); +}; + +export default MultipleChoice; diff --git a/frontend/src/components/PageNumber.module.css b/frontend/src/components/PageNumber.module.css new file mode 100644 index 0000000..baa68f8 --- /dev/null +++ b/frontend/src/components/PageNumber.module.css @@ -0,0 +1,10 @@ +.pageNumber { + color: var(--Primary-Background-Dark, #232220); + text-align: center; + /* Desktop/Body 2 */ + font-family: "Open Sans"; + font-size: 16px; + font-style: normal; + font-weight: 400; + line-height: normal; +} diff --git a/frontend/src/components/PageNumber.tsx b/frontend/src/components/PageNumber.tsx new file mode 100644 index 0000000..e009ef9 --- /dev/null +++ b/frontend/src/components/PageNumber.tsx @@ -0,0 +1,16 @@ +import React from "react"; +import styles from "src/components/PageNumber.module.css"; + +export interface PageNumberProps { + pageNum: number; +} + +const PageNumber = ({ pageNum }: PageNumberProps) => { + return ( +
+

Page {pageNum} of 3

+
+ ); +}; + +export default PageNumber; diff --git a/frontend/src/components/TextField.module.css b/frontend/src/components/TextField.module.css new file mode 100644 index 0000000..971ff18 --- /dev/null +++ b/frontend/src/components/TextField.module.css @@ -0,0 +1,41 @@ +.wrapperClass { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 4px; + flex: 1 0 0; + font-size: 16px; + color: var(--Light-Gray, #818181); + font-family: "Open Sans"; + font-style: normal; + font-weight: 400; + line-height: normal; +} + +.requiredAsterisk { + color: var(--Secondary-2, #be2d46); + text-align: left; +} + +.inputClass { + display: flex; + align-items: flex-start; + gap: 10px; + align-self: stretch; + color: var(--Dark-Gray, #484848); + font-family: "Open Sans"; + font-weight: 300; + line-height: normal; +} + +.helperText { + color: var(--Secondary-2, #be2d46); + font-size: 12px; +} + +.input::placeholder { + font-size: 16px; + font-style: italic; + font-weight: 300; + color: var(--Dark-Gray, #484848); +} diff --git a/frontend/src/components/TextField.tsx b/frontend/src/components/TextField.tsx new file mode 100644 index 0000000..34c6062 --- /dev/null +++ b/frontend/src/components/TextField.tsx @@ -0,0 +1,42 @@ +import styles from "src/components/TextField.module.css"; +import MUITextField, { TextFieldProps as MUITextFieldProps } from "@mui/material/TextField"; +import { ForwardedRef, forwardRef } from "react"; + +export interface TextFieldProps extends MUITextFieldProps<"outlined"> { + label: string; + error?: boolean; + helperText?: string; + required: boolean; +} + +const TextField = forwardRef( + ( + { label, error, required, helperText, ...props }: TextFieldProps, + ref: ForwardedRef, + ) => { + return ( +
+

+ {required ? * : null} + {label} +

+ + {helperText ?
{helperText}
: null} +
+ ); + }, +); +TextField.displayName = "TextField"; +export default TextField;