From 3750b8220cffa7a6567b3b350df7335a1832aafe Mon Sep 17 00:00:00 2001 From: Steven Shi <57511106+2s2e@users.noreply.github.com> Date: Sat, 24 Feb 2024 23:54:54 -0800 Subject: [PATCH] Feature/steven+yoto vsr personal info (#24) * lint fixes * Added route to POST and VSR controller * Added validation functions for form responses * Added validation functions for form responses * Removed numOfBoys, numOfGirls from schema, cleaned up backend, renamed martial to marital * Added tests for validate spouse name * Added tests for validate ethnicity * Basic text field, multiple choice, dropdown, and submit * Fix linting issues and minor edit to gender field validation * app now uses post route * Attempt at linking front end with back end, onbooarding code for front end api requests imported and adjusted * Reworked TextField component to do ref-forwarding, successfully pushed a document to the mongo database * Fixed couple bugs with date verification * Removed forwardRef * Added title and description, required fields, and overall more styling * updated date to use current date, added more fields for the vsr page * Prototype of ethnicity selector * Made options unselectable * Working version of ethnicity form * Prototype of appearing and disappearing children form * Added more styling * Added backend functionality for ages, changed Child #n name to Child #n age * Fixed small bug with ethnicity, added numBoys and spouse to createVSR, and continued styling * fixed issue with ethnicity selection * Changed textbox styling to flex 1 * Fixed issue with null values in age array * Page number component * added girls form * changes with validation * Yet even more lint fixes * Fix CSS styles & clean up code * Update favicon, title, & description, remove unused boilerplate * Remove commented-out code & make ethnicity an array * Fix bugs with entering ages of children * Only require spouse's name for Married marital status * Fix age validation * Fix bugs with VSR age & num children * Address Daniel's review comments --------- Co-authored-by: Yoto Kim Co-authored-by: Benjamin Johnson --- backend/src/app.ts | 3 + backend/src/controllers/vsr.ts | 51 +++ backend/src/models/vsr.ts | 20 + backend/src/routes/vsr.ts | 9 + backend/src/util/validationErrorParser.ts | 25 ++ backend/src/validators/vsr.ts | 97 ++++ frontend/package-lock.json | 386 ++++++++++++++-- frontend/package.json | 6 +- frontend/public/logo.svg | 9 + frontend/public/next.svg | 1 - frontend/public/vercel.svg | 1 - frontend/src/api/VSRs.ts | 75 ++++ frontend/src/api/requests.ts | 164 +++++++ frontend/src/app/dummyPage/layout.tsx | 4 +- frontend/src/app/favicon.ico | Bin 25931 -> 15406 bytes frontend/src/app/layout.tsx | 5 +- frontend/src/app/login/layout.tsx | 4 +- frontend/src/app/page.module.css | 227 ---------- frontend/src/app/vsr/page.module.css | 129 ++++++ frontend/src/app/vsr/page.tsx | 417 ++++++++++++++++++ frontend/src/components/Dropdown.module.css | 47 ++ frontend/src/components/Dropdown.tsx | 59 +++ frontend/src/components/HeaderBar.module.css | 14 + frontend/src/components/HeaderBar.tsx | 13 + .../src/components/MultipleChoice.module.css | 64 +++ frontend/src/components/MultipleChoice.tsx | 70 +++ frontend/src/components/PageNumber.module.css | 10 + frontend/src/components/PageNumber.tsx | 16 + frontend/src/components/TextField.module.css | 41 ++ frontend/src/components/TextField.tsx | 42 ++ 30 files changed, 1731 insertions(+), 278 deletions(-) create mode 100644 backend/src/controllers/vsr.ts create mode 100644 backend/src/models/vsr.ts create mode 100644 backend/src/routes/vsr.ts create mode 100644 backend/src/util/validationErrorParser.ts create mode 100644 backend/src/validators/vsr.ts create mode 100644 frontend/public/logo.svg delete mode 100644 frontend/public/next.svg delete mode 100644 frontend/public/vercel.svg create mode 100644 frontend/src/api/VSRs.ts create mode 100644 frontend/src/api/requests.ts create mode 100644 frontend/src/app/vsr/page.module.css create mode 100644 frontend/src/app/vsr/page.tsx create mode 100644 frontend/src/components/Dropdown.module.css create mode 100644 frontend/src/components/Dropdown.tsx create mode 100644 frontend/src/components/HeaderBar.module.css create mode 100644 frontend/src/components/HeaderBar.tsx create mode 100644 frontend/src/components/MultipleChoice.module.css create mode 100644 frontend/src/components/MultipleChoice.tsx create mode 100644 frontend/src/components/PageNumber.module.css create mode 100644 frontend/src/components/PageNumber.tsx create mode 100644 frontend/src/components/TextField.module.css create mode 100644 frontend/src/components/TextField.tsx 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 718d6fea4835ec2d246af9800eddb7ffb276240c..b643ed91520810b773f38808bb1f7a5c2c9cd2c7 100644 GIT binary patch literal 15406 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 XDl02L+gP0%l?VOr^FI!JGY9?;o=1KE literal 25931 zcmeHv30#a{`}aL_*G&7qml|y<+KVaDM2m#dVr!KsA!#An?kSQM(q<_dDNCpjEux83 zLb9Z^XxbDl(w>%i@8hT6>)&Gu{h#Oeyszu?xtw#Zb1mO{pgX9699l+Qppw7jXaYf~-84xW z)w4x8?=youko|}Vr~(D$UXIbiXABHh`p1?nn8Po~fxRJv}|0e(BPs|G`(TT%kKVJAdg5*Z|x0leQq0 zkdUBvb#>9F()jo|T~kx@OM8$9wzs~t2l;K=woNssA3l6|sx2r3+kdfVW@e^8e*E}v zA1y5{bRi+3Z`uD3{F7LgFJDdvm;nJilkzDku>BwXH(8ItVCXk*-lSJnR?-2UN%hJ){&rlvg`CDTj z)Bzo!3v7Ou#83zEDEFcKt(f1E0~=rqeEbTnMvWR#{+9pg%7G8y>u1OVRUSoox-ovF z2Ydma(;=YuBY(eI|04{hXzZD6_f(v~H;C~y5=DhAC{MMS>2fm~1H_t2$56pc$NH8( z5bH|<)71dV-_oCHIrzrT`2s-5w_+2CM0$95I6X8p^r!gHp+j_gd;9O<1~CEQQGS8) zS9Qh3#p&JM-G8rHekNmKVewU;pJRcTAog68KYo^dRo}(M>36U4Us zfgYWSiHZL3;lpWT=zNAW>Dh#mB!_@Lg%$ms8N-;aPqMn+C2HqZgz&9~Eu z4|Kp<`$q)Uw1R?y(~S>ePdonHxpV1#eSP1B;Ogo+-Pk}6#0GsZZ5!||ev2MGdh}_m z{DeR7?0-1^zVs&`AV6Vt;r3`I`OI_wgs*w=eO%_#7Kepl{B@xiyCANc(l zzIyd4y|c6PXWq9-|KM8(zIk8LPk(>a)zyFWjhT!$HJ$qX1vo@d25W<fvZQ2zUz5WRc(UnFMKHwe1| zWmlB1qdbiA(C0jmnV<}GfbKtmcu^2*P^O?MBLZKt|As~ge8&AAO~2K@zbXelK|4T<{|y4`raF{=72kC2Kn(L4YyenWgrPiv z@^mr$t{#X5VuIMeL!7Ab6_kG$&#&5p*Z{+?5U|TZ`B!7llpVmp@skYz&n^8QfPJzL z0G6K_OJM9x+Wu2gfN45phANGt{7=C>i34CV{Xqlx(fWpeAoj^N0Biu`w+MVcCUyU* zDZuzO0>4Z6fbu^T_arWW5n!E45vX8N=bxTVeFoep_G#VmNlQzAI_KTIc{6>c+04vr zx@W}zE5JNSU>!THJ{J=cqjz+4{L4A{Ob9$ZJ*S1?Ggg3klFp!+Y1@K+pK1DqI|_gq z5ZDXVpge8-cs!o|;K73#YXZ3AShj50wBvuq3NTOZ`M&qtjj#GOFfgExjg8Gn8>Vq5 z`85n+9|!iLCZF5$HJ$Iu($dm?8~-ofu}tEc+-pyke=3!im#6pk_Wo8IA|fJwD&~~F zc16osQ)EBo58U7XDuMexaPRjU@h8tXe%S{fA0NH3vGJFhuyyO!Uyl2^&EOpX{9As0 zWj+P>{@}jxH)8|r;2HdupP!vie{sJ28b&bo!8`D^x}TE$%zXNb^X1p@0PJ86`dZyj z%ce7*{^oo+6%&~I!8hQy-vQ7E)0t0ybH4l%KltWOo~8cO`T=157JqL(oq_rC%ea&4 z2NcTJe-HgFjNg-gZ$6!Y`SMHrlj}Etf7?r!zQTPPSv}{so2e>Fjs1{gzk~LGeesX%r(Lh6rbhSo_n)@@G-FTQy93;l#E)hgP@d_SGvyCp0~o(Y;Ee8{ zdVUDbHm5`2taPUOY^MAGOw*>=s7=Gst=D+p+2yON!0%Hk` zz5mAhyT4lS*T3LS^WSxUy86q&GnoHxzQ6vm8)VS}_zuqG?+3td68_x;etQAdu@sc6 zQJ&5|4(I?~3d-QOAODHpZ=hlSg(lBZ!JZWCtHHSj`0Wh93-Uk)_S%zsJ~aD>{`A0~ z9{AG(e|q3g5B%wYKRxiL2Y$8(4w6bzchKuloQW#e&S3n+P- z8!ds-%f;TJ1>)v)##>gd{PdS2Oc3VaR`fr=`O8QIO(6(N!A?pr5C#6fc~Ge@N%Vvu zaoAX2&(a6eWy_q&UwOhU)|P3J0Qc%OdhzW=F4D|pt0E4osw;%<%Dn58hAWD^XnZD= z>9~H(3bmLtxpF?a7su6J7M*x1By7YSUbxGi)Ot0P77`}P3{)&5Un{KD?`-e?r21!4vTTnN(4Y6Lin?UkSM z`MXCTC1@4A4~mvz%Rh2&EwY))LeoT=*`tMoqcEXI>TZU9WTP#l?uFv+@Dn~b(>xh2 z;>B?;Tz2SR&KVb>vGiBSB`@U7VIWFSo=LDSb9F{GF^DbmWAfpms8Sx9OX4CnBJca3 zlj9(x!dIjN?OG1X4l*imJNvRCk}F%!?SOfiOq5y^mZW)jFL@a|r-@d#f7 z2gmU8L3IZq0ynIws=}~m^#@&C%J6QFo~Mo4V`>v7MI-_!EBMMtb%_M&kvAaN)@ZVw z+`toz&WG#HkWDjnZE!6nk{e-oFdL^$YnbOCN}JC&{$#$O27@|Tn-skXr)2ml2~O!5 zX+gYoxhoc7qoU?C^3~&!U?kRFtnSEecWuH0B0OvLodgUAi}8p1 zrO6RSXHH}DMc$&|?D004DiOVMHV8kXCP@7NKB zgaZq^^O<7PoKEp72kby@W0Z!Y*Ay{&vfg#C&gG@YVR9g?FEocMUi1gSN$+V+ayF45{a zuDZDTN}mS|;BO%gEf}pjBfN2-gIrU#G5~cucA;dokXW89%>AyXJJI z9X4UlIWA|ZYHgbI z5?oFk@A=Ik7lrEQPDH!H+b`7_Y~aDb_qa=B2^Y&Ow41cU=4WDd40dp5(QS-WMN-=Y z9g;6_-JdNU;|6cPwf$ak*aJIcwL@1n$#l~zi{c{EW?T;DaW*E8DYq?Umtz{nJ&w-M zEMyTDrC&9K$d|kZe2#ws6)L=7K+{ zQw{XnV6UC$6-rW0emqm8wJoeZK)wJIcV?dST}Z;G0Arq{dVDu0&4kd%N!3F1*;*pW zR&qUiFzK=@44#QGw7k1`3t_d8&*kBV->O##t|tonFc2YWrL7_eqg+=+k;!F-`^b8> z#KWCE8%u4k@EprxqiV$VmmtiWxDLgnGu$Vs<8rppV5EajBXL4nyyZM$SWVm!wnCj-B!Wjqj5-5dNXukI2$$|Bu3Lrw}z65Lc=1G z^-#WuQOj$hwNGG?*CM_TO8Bg-1+qc>J7k5c51U8g?ZU5n?HYor;~JIjoWH-G>AoUP ztrWWLbRNqIjW#RT*WqZgPJXU7C)VaW5}MiijYbABmzoru6EmQ*N8cVK7a3|aOB#O& zBl8JY2WKfmj;h#Q!pN%9o@VNLv{OUL?rixHwOZuvX7{IJ{(EdPpuVFoQqIOa7giLVkBOKL@^smUA!tZ1CKRK}#SSM)iQHk)*R~?M!qkCruaS!#oIL1c z?J;U~&FfH#*98^G?i}pA{ z9Jg36t4=%6mhY(quYq*vSxptes9qy|7xSlH?G=S@>u>Ebe;|LVhs~@+06N<4CViBk zUiY$thvX;>Tby6z9Y1edAMQaiH zm^r3v#$Q#2T=X>bsY#D%s!bhs^M9PMAcHbCc0FMHV{u-dwlL;a1eJ63v5U*?Q_8JO zT#50!RD619#j_Uf))0ooADz~*9&lN!bBDRUgE>Vud-i5ck%vT=r^yD*^?Mp@Q^v+V zG#-?gKlr}Eeqifb{|So?HM&g91P8|av8hQoCmQXkd?7wIJwb z_^v8bbg`SAn{I*4bH$u(RZ6*xUhuA~hc=8czK8SHEKTzSxgbwi~9(OqJB&gwb^l4+m`k*Q;_?>Y-APi1{k zAHQ)P)G)f|AyjSgcCFps)Fh6Bca*Xznq36!pV6Az&m{O8$wGFD? zY&O*3*J0;_EqM#jh6^gMQKpXV?#1?>$ml1xvh8nSN>-?H=V;nJIwB07YX$e6vLxH( zqYwQ>qxwR(i4f)DLd)-$P>T-no_c!LsN@)8`e;W@)-Hj0>nJ-}Kla4-ZdPJzI&Mce zv)V_j;(3ERN3_@I$N<^|4Lf`B;8n+bX@bHbcZTopEmDI*Jfl)-pFDvo6svPRoo@(x z);_{lY<;);XzT`dBFpRmGrr}z5u1=pC^S-{ce6iXQlLGcItwJ^mZx{m$&DA_oEZ)B{_bYPq-HA zcH8WGoBG(aBU_j)vEy+_71T34@4dmSg!|M8Vf92Zj6WH7Q7t#OHQqWgFE3ARt+%!T z?oLovLVlnf?2c7pTc)~cc^($_8nyKwsN`RA-23ed3sdj(ys%pjjM+9JrctL;dy8a( z@en&CQmnV(()bu|Y%G1-4a(6x{aLytn$T-;(&{QIJB9vMox11U-1HpD@d(QkaJdEb zG{)+6Dos_L+O3NpWo^=gR?evp|CqEG?L&Ut#D*KLaRFOgOEK(Kq1@!EGcTfo+%A&I z=dLbB+d$u{sh?u)xP{PF8L%;YPPW53+@{>5W=Jt#wQpN;0_HYdw1{ksf_XhO4#2F= zyPx6Lx2<92L-;L5PD`zn6zwIH`Jk($?Qw({erA$^bC;q33hv!d!>%wRhj# zal^hk+WGNg;rJtb-EB(?czvOM=H7dl=vblBwAv>}%1@{}mnpUznfq1cE^sgsL0*4I zJ##!*B?=vI_OEVis5o+_IwMIRrpQyT_Sq~ZU%oY7c5JMIADzpD!Upz9h@iWg_>>~j zOLS;wp^i$-E?4<_cp?RiS%Rd?i;f*mOz=~(&3lo<=@(nR!_Rqiprh@weZlL!t#NCc zO!QTcInq|%#>OVgobj{~ixEUec`E25zJ~*DofsQdzIa@5^nOXj2T;8O`l--(QyU^$t?TGY^7#&FQ+2SS3B#qK*k3`ye?8jUYSajE5iBbJls75CCc(m3dk{t?- zopcER9{Z?TC)mk~gpi^kbbu>b-+a{m#8-y2^p$ka4n60w;Sc2}HMf<8JUvhCL0B&Btk)T`ctE$*qNW8L$`7!r^9T+>=<=2qaq-;ll2{`{Rg zc5a0ZUI$oG&j-qVOuKa=*v4aY#IsoM+1|c4Z)<}lEDvy;5huB@1RJPquU2U*U-;gu z=En2m+qjBzR#DEJDO`WU)hdd{Vj%^0V*KoyZ|5lzV87&g_j~NCjwv0uQVqXOb*QrQ zy|Qn`hxx(58c70$E;L(X0uZZ72M1!6oeg)(cdKO ze0gDaTz+ohR-#d)NbAH4x{I(21yjwvBQfmpLu$)|m{XolbgF!pmsqJ#D}(ylp6uC> z{bqtcI#hT#HW=wl7>p!38sKsJ`r8}lt-q%Keqy%u(xk=yiIJiUw6|5IvkS+#?JTBl z8H5(Q?l#wzazujH!8o>1xtn8#_w+397*_cy8!pQGP%K(Ga3pAjsaTbbXJlQF_+m+-UpUUent@xM zg%jqLUExj~o^vQ3Gl*>wh=_gOr2*|U64_iXb+-111aH}$TjeajM+I20xw(((>fej-@CIz4S1pi$(#}P7`4({6QS2CaQS4NPENDp>sAqD z$bH4KGzXGffkJ7R>V>)>tC)uax{UsN*dbeNC*v}#8Y#OWYwL4t$ePR?VTyIs!wea+ z5Urmc)X|^`MG~*dS6pGSbU+gPJoq*^a=_>$n4|P^w$sMBBy@f*Z^Jg6?n5?oId6f{ z$LW4M|4m502z0t7g<#Bx%X;9<=)smFolV&(V^(7Cv2-sxbxopQ!)*#ZRhTBpx1)Fc zNm1T%bONzv6@#|dz(w02AH8OXe>kQ#1FMCzO}2J_mST)+ExmBr9cva-@?;wnmWMOk z{3_~EX_xadgJGv&H@zK_8{(x84`}+c?oSBX*Ge3VdfTt&F}yCpFP?CpW+BE^cWY0^ zb&uBN!Ja3UzYHK-CTyA5=L zEMW{l3Usky#ly=7px648W31UNV@K)&Ub&zP1c7%)`{);I4b0Q<)B}3;NMG2JH=X$U zfIW4)4n9ZM`-yRj67I)YSLDK)qfUJ_ij}a#aZN~9EXrh8eZY2&=uY%2N0UFF7<~%M zsB8=erOWZ>Ct_#^tHZ|*q`H;A)5;ycw*IcmVxi8_0Xk}aJA^ath+E;xg!x+As(M#0=)3!NJR6H&9+zd#iP(m0PIW8$ z1Y^VX`>jm`W!=WpF*{ioM?C9`yOR>@0q=u7o>BP-eSHqCgMDj!2anwH?s%i2p+Q7D zzszIf5XJpE)IG4;d_(La-xenmF(tgAxK`Y4sQ}BSJEPs6N_U2vI{8=0C_F?@7<(G; zo$~G=8p+076G;`}>{MQ>t>7cm=zGtfbdDXm6||jUU|?X?CaE?(<6bKDYKeHlz}DA8 zXT={X=yp_R;HfJ9h%?eWvQ!dRgz&Su*JfNt!Wu>|XfU&68iRikRrHRW|ZxzRR^`eIGt zIeiDgVS>IeExKVRWW8-=A=yA`}`)ZkWBrZD`hpWIxBGkh&f#ijr449~m`j6{4jiJ*C!oVA8ZC?$1RM#K(_b zL9TW)kN*Y4%^-qPpMP7d4)o?Nk#>aoYHT(*g)qmRUb?**F@pnNiy6Fv9rEiUqD(^O zzyS?nBrX63BTRYduaG(0VVG2yJRe%o&rVrLjbxTaAFTd8s;<<@Qs>u(<193R8>}2_ zuwp{7;H2a*X7_jryzriZXMg?bTuegABb^87@SsKkr2)0Gyiax8KQWstw^v#ix45EVrcEhr>!NMhprl$InQMzjSFH54x5k9qHc`@9uKQzvL4ihcq{^B zPrVR=o_ic%Y>6&rMN)hTZsI7I<3&`#(nl+3y3ys9A~&^=4?PL&nd8)`OfG#n zwAMN$1&>K++c{^|7<4P=2y(B{jJsQ0a#U;HTo4ZmWZYvI{+s;Td{Yzem%0*k#)vjpB zia;J&>}ICate44SFYY3vEelqStQWFihx%^vQ@Do(sOy7yR2@WNv7Y9I^yL=nZr3mb zXKV5t@=?-Sk|b{XMhA7ZGB@2hqsx}4xwCW!in#C zI@}scZlr3-NFJ@NFaJlhyfcw{k^vvtGl`N9xSo**rDW4S}i zM9{fMPWo%4wYDG~BZ18BD+}h|GQKc-g^{++3MY>}W_uq7jGHx{mwE9fZiPCoxN$+7 zrODGGJrOkcPQUB(FD5aoS4g~7#6NR^ma7-!>mHuJfY5kTe6PpNNKC9GGRiu^L31uG z$7v`*JknQHsYB!Tm_W{a32TM099djW%5e+j0Ve_ct}IM>XLF1Ap+YvcrLV=|CKo6S zb+9Nl3_YdKP6%Cxy@6TxZ>;4&nTneadr z_ES90ydCev)LV!dN=#(*f}|ZORFdvkYBni^aLbUk>BajeWIOcmHP#8S)*2U~QKI%S zyrLmtPqb&TphJ;>yAxri#;{uyk`JJqODDw%(Z=2`1uc}br^V%>j!gS)D*q*f_-qf8&D;W1dJgQMlaH5er zN2U<%Smb7==vE}dDI8K7cKz!vs^73o9f>2sgiTzWcwY|BMYHH5%Vn7#kiw&eItCqa zIkR2~Q}>X=Ar8W|^Ms41Fm8o6IB2_j60eOeBB1Br!boW7JnoeX6Gs)?7rW0^5psc- zjS16yb>dFn>KPOF;imD}e!enuIniFzv}n$m2#gCCv4jM#ArwlzZ$7@9&XkFxZ4n!V zj3dyiwW4Ki2QG{@i>yuZXQizw_OkZI^-3otXC{!(lUpJF33gI60ak;Uqitp74|B6I zgg{b=Iz}WkhCGj1M=hu4#Aw173YxIVbISaoc z-nLZC*6Tgivd5V`K%GxhBsp@SUU60-rfc$=wb>zdJzXS&-5(NRRodFk;Kxk!S(O(a0e7oY=E( zAyS;Ow?6Q&XA+cnkCb{28_1N8H#?J!*$MmIwLq^*T_9-z^&UE@A(z9oGYtFy6EZef LrJugUA?W`A8`#=m 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;