├── .eslintrc.json ├── chartjs-adapter-date-fns.d.ts ├── public ├── car.png ├── end.png ├── logo.png ├── start.png ├── user.png ├── checkbox.png ├── sendIcon.png ├── rider-dest.png ├── user-dest.png ├── blue-circle.png ├── driver-dest.png ├── driver-start.png ├── rider-start.png └── user-dest-driver.png ├── .husky └── pre-commit ├── postcss.config.js ├── prisma └── migrations │ ├── 20241119202706_add_text_type │ └── migration.sql │ ├── 20231013160001_image_length_extension │ └── migration.sql │ ├── 20230802190124_azure_oauth │ └── migration.sql │ ├── 20231012170657_legal │ └── migration.sql │ ├── 20240801200019_request_message_length │ └── migration.sql │ ├── 20221109205003_preferred_name │ └── migration.sql │ ├── migration_lock.toml │ ├── 20221016171823_optional_times │ └── migration.sql │ ├── 20240729184022_added_viewer_role │ └── migration.sql │ ├── 20230813202330_group_name_fix │ └── migration.sql │ ├── 20231013145551_account_text_length_fix │ └── migration.sql │ ├── 20231001194413_group_message │ └── migration.sql │ ├── 20230217001802_location_address │ └── migration.sql │ ├── 20230630045341_carpool_user_fix │ └── migration.sql │ ├── 20230414010032_group_id_to_string │ └── migration.sql │ ├── 20230203005137_ │ └── migration.sql │ ├── 20230203001945_added_favorites │ └── migration.sql │ ├── 20241008151811_migration │ └── migration.sql │ ├── 20230414010607_invs_to_reqs │ └── migration.sql │ ├── 20241119202005_increase_group_message_length │ └── migration.sql │ ├── 20221016165304_added_user_info │ └── migration.sql │ ├── 20240910182030_conversationmodel │ └── migration.sql │ ├── 20230414004248_groups_invitations │ └── migration.sql │ └── 20221009042524_base_schema │ └── migration.sql ├── src ├── utils │ ├── classNames.ts │ ├── userContext.ts │ ├── env │ │ ├── browser.ts │ │ └── server.ts │ ├── map │ │ ├── updateGeoJsonUsers.ts │ │ ├── updateUserLocation.ts │ │ ├── PulsingDot.ts │ │ ├── addMapEvents.tsx │ │ ├── updateCompanyLocation.ts │ │ └── addClusters.ts │ ├── profile │ │ ├── useUploadFile.ts │ │ ├── updateUser.ts │ │ └── zodSchema.ts │ ├── useAddressSelection.ts │ ├── dateUtils.ts │ ├── latestMessage.ts │ ├── adminDataUtils.ts │ ├── cropImage.ts │ ├── mixpanel.ts │ ├── search.ts │ ├── useProfileImage.ts │ ├── trpc.ts │ ├── uploadToS3.ts │ ├── email.ts │ ├── publicUser.ts │ └── requestHandlers.ts ├── server │ ├── db │ │ ├── client.ts │ │ └── README.md │ └── router │ │ ├── index.ts │ │ ├── createRouter.ts │ │ ├── context.ts │ │ ├── user │ │ ├── favorites.ts │ │ ├── admin.ts │ │ ├── recommendations.ts │ │ ├── message.ts │ │ └── email.ts │ │ └── README.md ├── components │ ├── Setup │ │ ├── SetupContainer.tsx │ │ ├── ProgressBar.tsx │ │ ├── FormRadioButton.tsx │ │ ├── StepFour.tsx │ │ ├── StepTwo.tsx │ │ └── InitialStep.tsx │ ├── Profile │ │ ├── ControlledCheckbox.tsx │ │ ├── DayBox.tsx │ │ ├── UnsavedModal.tsx │ │ ├── ControlledTimePicker.tsx │ │ ├── ProfileSidebar.tsx │ │ └── ControlledAddressCombobox.tsx │ ├── Sidebar │ │ ├── StaticDayBox.tsx │ │ ├── RequestSidebar.tsx │ │ ├── Sidebar.tsx │ │ ├── CustomSelect.tsx │ │ └── ExploreSidebar.tsx │ ├── Spinner.tsx │ ├── Admin │ │ ├── AdminSidebar.tsx │ │ ├── AdminData.tsx │ │ └── BarChartUserCounts.tsx │ ├── EntryLabel.tsx │ ├── Map │ │ ├── InactiveBlocker.tsx │ │ ├── VisibilityToggle.tsx │ │ └── AddressCombobox.tsx │ ├── UserCards │ │ ├── ReceivedCard.tsx │ │ ├── SentCard.tsx │ │ └── ConnectCard.tsx │ ├── MapLegend.tsx │ ├── Messages │ │ ├── SendBar.tsx │ │ ├── MessageHeader.tsx │ │ └── data.json │ ├── TextField.tsx │ ├── MapConnectPortal.tsx │ ├── Radio.tsx │ ├── Modals │ │ └── SentRequestModal.tsx │ ├── DropDownMenu.tsx │ ├── Group │ │ └── GroupMemberCard.tsx │ └── CompliancePortal.tsx ├── pages │ ├── api │ │ ├── trpc │ │ │ └── [trpc].ts │ │ ├── geocoding.ts │ │ └── auth │ │ │ └── [...nextauth].ts │ ├── _document.tsx │ ├── _app.tsx │ ├── admin.tsx │ └── sign-in.tsx └── styles │ ├── globals.css │ └── profile.ts ├── vercel.sh ├── next-env.d.ts ├── next.config.js ├── .github └── workflows │ ├── lint.yml │ ├── test.yml │ ├── tsc.yml │ └── auto-comment.yml ├── docker-compose.yml ├── next-auth.d.ts ├── .gitignore ├── tsconfig.json ├── README.md ├── tailwind.config.js └── package.json /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "next/core-web-vitals" 3 | } 4 | -------------------------------------------------------------------------------- /chartjs-adapter-date-fns.d.ts: -------------------------------------------------------------------------------- 1 | declare module "chartjs-adapter-date-fns"; 2 | -------------------------------------------------------------------------------- /public/car.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sandboxnu/nucarpool/main/public/car.png -------------------------------------------------------------------------------- /public/end.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sandboxnu/nucarpool/main/public/end.png -------------------------------------------------------------------------------- /public/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sandboxnu/nucarpool/main/public/logo.png -------------------------------------------------------------------------------- /public/start.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sandboxnu/nucarpool/main/public/start.png -------------------------------------------------------------------------------- /public/user.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sandboxnu/nucarpool/main/public/user.png -------------------------------------------------------------------------------- /public/checkbox.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sandboxnu/nucarpool/main/public/checkbox.png -------------------------------------------------------------------------------- /public/sendIcon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sandboxnu/nucarpool/main/public/sendIcon.png -------------------------------------------------------------------------------- /public/rider-dest.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sandboxnu/nucarpool/main/public/rider-dest.png -------------------------------------------------------------------------------- /public/user-dest.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sandboxnu/nucarpool/main/public/user-dest.png -------------------------------------------------------------------------------- /public/blue-circle.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sandboxnu/nucarpool/main/public/blue-circle.png -------------------------------------------------------------------------------- /public/driver-dest.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sandboxnu/nucarpool/main/public/driver-dest.png -------------------------------------------------------------------------------- /public/driver-start.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sandboxnu/nucarpool/main/public/driver-start.png -------------------------------------------------------------------------------- /public/rider-start.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sandboxnu/nucarpool/main/public/rider-start.png -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | npx pretty-quick --staged 5 | -------------------------------------------------------------------------------- /public/user-dest-driver.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sandboxnu/nucarpool/main/public/user-dest-driver.png -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | }; 7 | -------------------------------------------------------------------------------- /prisma/migrations/20241119202706_add_text_type/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterTable 2 | ALTER TABLE `group` MODIFY `message` TEXT NOT NULL; 3 | -------------------------------------------------------------------------------- /prisma/migrations/20231013160001_image_length_extension/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterTable 2 | ALTER TABLE `user` MODIFY `image` MEDIUMTEXT NULL; 3 | -------------------------------------------------------------------------------- /prisma/migrations/20230802190124_azure_oauth/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterTable 2 | ALTER TABLE `account` ADD COLUMN `ext_expires_in` INTEGER NULL; 3 | -------------------------------------------------------------------------------- /prisma/migrations/20231012170657_legal/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterTable 2 | ALTER TABLE `user` ADD COLUMN `license_signed` BOOLEAN NOT NULL DEFAULT false; 3 | -------------------------------------------------------------------------------- /prisma/migrations/20240801200019_request_message_length/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterTable 2 | ALTER TABLE `request` MODIFY `message` VARCHAR(255) NOT NULL; 3 | -------------------------------------------------------------------------------- /prisma/migrations/20221109205003_preferred_name/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterTable 2 | ALTER TABLE `user` ADD COLUMN `preferredName` VARCHAR(191) NOT NULL DEFAULT ''; 3 | -------------------------------------------------------------------------------- /src/utils/classNames.ts: -------------------------------------------------------------------------------- 1 | export function classNames(...classes: (string | undefined | null | false)[]) { 2 | return classes.filter(Boolean).join(" "); 3 | } 4 | -------------------------------------------------------------------------------- /prisma/migrations/migration_lock.toml: -------------------------------------------------------------------------------- 1 | # Please do not edit this file manually 2 | # It should be added in your version-control system (i.e. Git) 3 | provider = "mysql" -------------------------------------------------------------------------------- /prisma/migrations/20221016171823_optional_times/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterTable 2 | ALTER TABLE `user` MODIFY `end_time` TIME(0) NULL, 3 | MODIFY `start_time` TIME(0) NULL; 4 | -------------------------------------------------------------------------------- /prisma/migrations/20240729184022_added_viewer_role/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterTable 2 | ALTER TABLE `user` MODIFY `role` ENUM('RIDER', 'DRIVER', 'VIEWER') NOT NULL DEFAULT 'RIDER'; 3 | -------------------------------------------------------------------------------- /src/utils/userContext.ts: -------------------------------------------------------------------------------- 1 | import { createContext, useContext } from "react"; 2 | import { User } from "./types"; 3 | 4 | export const UserContext = createContext(null); 5 | -------------------------------------------------------------------------------- /vercel.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | if [[ $VERCEL_GIT_COMMIT_REF == "main" ]] ; then 4 | echo "Deploying to production..." 5 | npm run build:main 6 | else 7 | echo "Deploying to preview..." 8 | npm run build:preview 9 | fi -------------------------------------------------------------------------------- /next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | 4 | // NOTE: This file should not be edited 5 | // see https://nextjs.org/docs/basic-features/typescript for more information. 6 | -------------------------------------------------------------------------------- /prisma/migrations/20230813202330_group_name_fix/migration.sql: -------------------------------------------------------------------------------- 1 | /* 2 | Warnings: 3 | 4 | - You are about to drop the column `name` on the `group` table. All the data in the column will be lost. 5 | 6 | */ 7 | -- AlterTable 8 | ALTER TABLE `group` DROP COLUMN `name`; 9 | -------------------------------------------------------------------------------- /prisma/migrations/20231013145551_account_text_length_fix/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterTable 2 | ALTER TABLE `account` MODIFY `refresh_token` MEDIUMTEXT NULL, 3 | MODIFY `access_token` MEDIUMTEXT NULL, 4 | MODIFY `scope` MEDIUMTEXT NULL, 5 | MODIFY `id_token` MEDIUMTEXT NULL; 6 | -------------------------------------------------------------------------------- /prisma/migrations/20231001194413_group_message/migration.sql: -------------------------------------------------------------------------------- 1 | /* 2 | Warnings: 3 | 4 | - Added the required column `message` to the `group` table without a default value. This is not possible if the table is not empty. 5 | 6 | */ 7 | -- AlterTable 8 | ALTER TABLE `group` ADD COLUMN `message` VARCHAR(191) NOT NULL; 9 | -------------------------------------------------------------------------------- /prisma/migrations/20230217001802_location_address/migration.sql: -------------------------------------------------------------------------------- 1 | /* 2 | Warnings: 3 | 4 | - You are about to drop the column `start_location` on the `user` table. All the data in the column will be lost. 5 | 6 | */ 7 | -- AlterTable 8 | ALTER TABLE `user` DROP COLUMN `start_location`, 9 | ADD COLUMN `start_address` VARCHAR(191) NOT NULL DEFAULT ''; 10 | -------------------------------------------------------------------------------- /src/server/db/client.ts: -------------------------------------------------------------------------------- 1 | import { PrismaClient } from "@prisma/client"; 2 | 3 | declare global { 4 | var prisma: PrismaClient | undefined; 5 | } 6 | 7 | export const prisma = 8 | global.prisma || 9 | new PrismaClient({ 10 | log: ["info", "warn", "error"], 11 | }); 12 | 13 | if (process.env.NODE_ENV !== "production") global.prisma = prisma; 14 | -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = { 3 | reactStrictMode: true, 4 | images: { 5 | domains: [ 6 | "lh3.googleusercontent.com", 7 | "carpoolnubucket.s3.us-east-2.amazonaws.com", 8 | ], 9 | }, 10 | compiler: { 11 | styledComponents: true, 12 | }, 13 | }; 14 | 15 | module.exports = nextConfig; -------------------------------------------------------------------------------- /src/utils/env/browser.ts: -------------------------------------------------------------------------------- 1 | import { bool, envsafe, str } from "envsafe"; 2 | 3 | export const browserEnv = envsafe({ 4 | NEXT_PUBLIC_MAPBOX_ACCESS_TOKEN: str({ 5 | input: process.env.NEXT_PUBLIC_MAPBOX_ACCESS_TOKEN, 6 | }), 7 | NEXT_PUBLIC_MIXPANEL_PROJECT_TOKEN: str({ 8 | input: process.env.NEXT_PUBLIC_MIXPANEL_PROJECT_TOKEN, 9 | }), 10 | }); 11 | -------------------------------------------------------------------------------- /src/components/Setup/SetupContainer.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | export const SetupContainer = ({ 4 | children, 5 | className, 6 | }: { 7 | children: React.ReactNode; 8 | className?: string; 9 | }) => { 10 | return ( 11 |
12 | {children} 13 |
14 | ); 15 | }; 16 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: Lint 2 | 3 | on: pull_request 4 | 5 | jobs: 6 | lint: 7 | name: lint 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/checkout@v2 11 | - name: install node v18 12 | uses: actions/setup-node@v1 13 | with: 14 | node-version: 18 15 | - run: yarn install 16 | - run: yarn lint 17 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: [push] 4 | 5 | jobs: 6 | test: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v1 10 | - name: install node v18.16 11 | uses: actions/setup-node@v1 12 | with: 13 | node-version: 18.16 14 | - run: yarn install 15 | - run: yarn test --passWithNoTests 16 | 17 | -------------------------------------------------------------------------------- /.github/workflows/tsc.yml: -------------------------------------------------------------------------------- 1 | name: Check Typescript Types 2 | 3 | on: [push] 4 | 5 | jobs: 6 | tsc: 7 | name: tsc 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/checkout@v1 11 | - name: install node v18 12 | uses: actions/setup-node@v1 13 | with: 14 | node-version: 18 15 | - run: yarn install 16 | - run: yarn tsc 17 | -------------------------------------------------------------------------------- /prisma/migrations/20230630045341_carpool_user_fix/migration.sql: -------------------------------------------------------------------------------- 1 | /* 2 | Warnings: 3 | 4 | - You are about to drop the `_userCarpools` table. If the table is not empty, all the data it contains will be lost. 5 | 6 | */ 7 | -- AlterTable 8 | ALTER TABLE `user` ADD COLUMN `carpoolId` VARCHAR(191) NULL; 9 | 10 | -- DropTable 11 | DROP TABLE `_userCarpools`; 12 | 13 | -- CreateIndex 14 | CREATE INDEX `user_carpoolId_idx` ON `user`(`carpoolId`); 15 | -------------------------------------------------------------------------------- /prisma/migrations/20230414010032_group_id_to_string/migration.sql: -------------------------------------------------------------------------------- 1 | /* 2 | Warnings: 3 | 4 | - The primary key for the `group` table will be changed. If it partially fails, the table could be left without primary key constraint. 5 | 6 | */ 7 | -- AlterTable 8 | ALTER TABLE `_userCarpools` MODIFY `A` VARCHAR(191) NOT NULL; 9 | 10 | -- AlterTable 11 | ALTER TABLE `group` DROP PRIMARY KEY, 12 | MODIFY `id` VARCHAR(191) NOT NULL, 13 | ADD PRIMARY KEY (`id`); 14 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3" 2 | 3 | services: 4 | mysql: 5 | platform: linux/x86_64 6 | container_name: mysql-on-docker 7 | image: mysql:5.7 8 | ports: 9 | - "${MYSQL_PORT}:3306" 10 | expose: 11 | - "3306" 12 | environment: 13 | MYSQL_ROOT_PASSWORD: "${MYSQL_ROOT_PASSWORD}" 14 | MYSQL_DATABASE: "${MYSQL_DATABASE}" 15 | volumes: 16 | - ./nucarpool-db-data:/var/lib/mysql 17 | 18 | volumes: 19 | nucarpool-db-data: 20 | -------------------------------------------------------------------------------- /prisma/migrations/20230203005137_/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterTable 2 | ALTER TABLE `user` ADD COLUMN `company_poi_address` VARCHAR(191) NOT NULL DEFAULT '', 3 | ADD COLUMN `company_poi_coord_lat` DOUBLE NOT NULL DEFAULT 0, 4 | ADD COLUMN `company_poi_coord_lng` DOUBLE NOT NULL DEFAULT 0, 5 | ADD COLUMN `start_poi_coord_lat` DOUBLE NOT NULL DEFAULT 0, 6 | ADD COLUMN `start_poi_coord_lng` DOUBLE NOT NULL DEFAULT 0, 7 | ADD COLUMN `start_poi_location` VARCHAR(191) NOT NULL DEFAULT ''; 8 | -------------------------------------------------------------------------------- /prisma/migrations/20230203001945_added_favorites/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateTable 2 | CREATE TABLE `_Favorites` ( 3 | `A` VARCHAR(191) NOT NULL, 4 | `B` VARCHAR(191) NOT NULL, 5 | 6 | UNIQUE INDEX `_Favorites_AB_unique`(`A`, `B`), 7 | INDEX `_Favorites_B_index`(`B`) 8 | ) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; 9 | 10 | -- CreateIndex 11 | CREATE INDEX `account_user_id_idx` ON `account`(`user_id`); 12 | 13 | -- CreateIndex 14 | CREATE INDEX `session_userId_idx` ON `session`(`userId`); 15 | -------------------------------------------------------------------------------- /src/server/router/index.ts: -------------------------------------------------------------------------------- 1 | import { router } from "./createRouter"; 2 | import { mapboxRouter } from "./mapbox"; 3 | import { userRouter } from "./user"; 4 | 5 | // This bundles together our distinct routers into one router, with prefixes for respective routers 6 | // The superjson transformer does JSON type conversion for Dates, Maps, and Sets for us :D 7 | export const appRouter = router({ 8 | user: userRouter, 9 | mapbox: mapboxRouter, 10 | }); 11 | 12 | // export type definition of API 13 | export type AppRouter = typeof appRouter; 14 | -------------------------------------------------------------------------------- /src/utils/map/updateGeoJsonUsers.ts: -------------------------------------------------------------------------------- 1 | import { GeoJsonUsers } from "../types"; 2 | import mapboxgl from "mapbox-gl"; 3 | import addClusters from "./addClusters"; 4 | import { Map } from "mapbox-gl"; 5 | 6 | const updateGeoJsonUsers = (map: Map, geoJsonUsers: GeoJsonUsers) => { 7 | if (map.getSource("company-locations")) { 8 | const source = map.getSource("company-locations") as mapboxgl.GeoJSONSource; 9 | source.setData(geoJsonUsers); 10 | } else { 11 | addClusters(map, geoJsonUsers); 12 | } 13 | }; 14 | export default updateGeoJsonUsers; 15 | -------------------------------------------------------------------------------- /src/components/Profile/ControlledCheckbox.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from "react"; 2 | import Checkbox from "@mui/material/Checkbox"; 3 | 4 | export default function ControlledCheckbox() { 5 | const [checked, setChecked] = useState(false); 6 | 7 | const handleChange = (event: React.ChangeEvent) => { 8 | setChecked(event.target.checked); 9 | }; 10 | 11 | return ( 12 | 17 | ); 18 | } 19 | -------------------------------------------------------------------------------- /src/pages/api/trpc/[trpc].ts: -------------------------------------------------------------------------------- 1 | import * as trpcNext from "@trpc/server/adapters/next"; 2 | import { appRouter } from "../../../server/router"; 3 | import { createContext } from "../../../server/router/context"; 4 | 5 | export default trpcNext.createNextApiHandler({ 6 | router: appRouter, 7 | createContext: createContext, 8 | onError({ error }) { 9 | if (error.code === "INTERNAL_SERVER_ERROR") { 10 | // send to bug reporting 11 | console.error("Something went wrong", error); 12 | } 13 | }, 14 | batching: { 15 | enabled: true, 16 | }, 17 | }); 18 | -------------------------------------------------------------------------------- /next-auth.d.ts: -------------------------------------------------------------------------------- 1 | import { DefaultSession } from "next-auth"; 2 | import { Permission } from "@prisma/client"; 3 | 4 | declare module "next-auth" { 5 | /** 6 | * Returned by `useSession`, `getSession` and received as a prop on the `SessionProvider` React Context 7 | */ 8 | interface Session { 9 | user?: { 10 | id?: string; 11 | isOnboarded: boolean; 12 | permission: Permission; 13 | } & DefaultSession["user"]; 14 | } 15 | 16 | interface User extends DefaultUser { 17 | isOnboarded: boolean; 18 | permission: Permission; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /.github/workflows/auto-comment.yml: -------------------------------------------------------------------------------- 1 | name: Auto Comment 2 | on: 3 | pull_request: 4 | paths: 5 | - "prisma/schema.prisma" 6 | 7 | jobs: 8 | run: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: wow-actions/auto-comment@v1 12 | with: 13 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 14 | pullRequestOpened: | 15 | 👋 @{{ author }} 16 | Looks like your PR might contain changes to the schema.prisma file. Please open a deploy request on PlanetScale before merging. If you have any questions, ping @lukejianu on Slack. 17 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | *.pem 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | .pnpm-debug.log* 27 | 28 | # local env files 29 | .env*.local 30 | .env 31 | 32 | # vercel 33 | .vercel 34 | 35 | # typescript 36 | *.tsbuildinfo 37 | 38 | # local DB 39 | nucarpool-db-data/ 40 | -------------------------------------------------------------------------------- /src/pages/_document.tsx: -------------------------------------------------------------------------------- 1 | import { Html, Head, Main, NextScript } from "next/document"; 2 | 3 | export default function Document() { 4 | return ( 5 | 6 | 7 | 11 | 15 | 16 | 17 |
18 | 19 | 20 | 21 | ); 22 | } 23 | -------------------------------------------------------------------------------- /src/components/Profile/DayBox.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | const DayBox = ({ 4 | day, 5 | isSelected, 6 | }: { 7 | day: string; 8 | isSelected: boolean; 9 | }): React.ReactElement => { 10 | const baseClasses = 11 | "flex md:h-14 md:w-14 lg:h-16 lg:w-16 sm:h-10 sm:w-10 items-center justify-center rounded-full ml-2 border border-black lg:text-2xl md:text-lg"; 12 | 13 | const selectedClasses = isSelected 14 | ? "bg-northeastern-red text-white" 15 | : "bg-white text-black"; 16 | 17 | return
{day}
; 18 | }; 19 | 20 | export default DayBox; 21 | -------------------------------------------------------------------------------- /prisma/migrations/20241008151811_migration/migration.sql: -------------------------------------------------------------------------------- 1 | /* 2 | Warnings: 3 | 4 | - Made the column `userId` on table `message` required. This step will fail if there are existing NULL values in that column. 5 | 6 | */ 7 | -- AlterTable 8 | ALTER TABLE `message` ADD COLUMN `dateCreated` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), 9 | MODIFY `userId` VARCHAR(191) NOT NULL; 10 | 11 | -- AlterTable 12 | ALTER TABLE `request` ADD COLUMN `conversationId` VARCHAR(191) NULL, 13 | ADD COLUMN `dateCreated` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3); 14 | 15 | -- CreateIndex 16 | CREATE INDEX `request_conversationId_idx` ON `request`(`conversationId`); 17 | -------------------------------------------------------------------------------- /prisma/migrations/20230414010607_invs_to_reqs/migration.sql: -------------------------------------------------------------------------------- 1 | /* 2 | Warnings: 3 | 4 | - You are about to drop the `invitation` table. If the table is not empty, all the data it contains will be lost. 5 | 6 | */ 7 | -- DropTable 8 | DROP TABLE `invitation`; 9 | 10 | -- CreateTable 11 | CREATE TABLE `request` ( 12 | `id` VARCHAR(191) NOT NULL, 13 | `message` VARCHAR(191) NOT NULL, 14 | `fromUserId` VARCHAR(191) NOT NULL, 15 | `toUserId` VARCHAR(191) NOT NULL, 16 | 17 | INDEX `request_fromUserId_idx`(`fromUserId`), 18 | INDEX `request_toUserId_idx`(`toUserId`), 19 | PRIMARY KEY (`id`) 20 | ) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; 21 | -------------------------------------------------------------------------------- /src/components/Setup/ProgressBar.tsx: -------------------------------------------------------------------------------- 1 | interface ProgressBarProps { 2 | step: number; 3 | } 4 | 5 | const ProgressBar = ({ step }: ProgressBarProps) => { 6 | return ( 7 |
8 | {Array.from({ length: 3 }).map((_, i) => ( 9 |
19 | ))} 20 |
21 | ); 22 | }; 23 | export default ProgressBar; 24 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "forceConsistentCasingInFileNames": true, 9 | "noEmit": true, 10 | "esModuleInterop": true, 11 | "module": "esnext", 12 | "moduleResolution": "node", 13 | "resolveJsonModule": true, 14 | "isolatedModules": true, 15 | "jsx": "preserve", 16 | "incremental": true 17 | }, 18 | "include": ["next-env.d.ts", "next-auth.d.ts", "**/*.ts", "**/*.tsx"], 19 | "exclude": ["node_modules"], 20 | "ts-node": { 21 | "compilerOptions": { 22 | "module": "commonjs" 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /prisma/migrations/20241119202005_increase_group_message_length/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterTable 2 | ALTER TABLE `conversation` ADD COLUMN `dateCreated` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3); 3 | 4 | -- AlterTable 5 | ALTER TABLE `group` ADD COLUMN `dateCreated` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3); 6 | 7 | -- AlterTable 8 | ALTER TABLE `user` ADD COLUMN `coop_end_date` DATE NULL, 9 | ADD COLUMN `coop_start_date` DATE NULL, 10 | ADD COLUMN `dateCreated` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), 11 | ADD COLUMN `dateModified` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), 12 | ADD COLUMN `group_message` TEXT NULL, 13 | MODIFY `role` ENUM('RIDER', 'DRIVER', 'VIEWER') NOT NULL DEFAULT 'VIEWER'; 14 | -------------------------------------------------------------------------------- /src/components/Sidebar/StaticDayBox.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { className } from "postcss-selector-parser"; 3 | 4 | const StaticDayBox = ({ 5 | day, 6 | isSelected, 7 | className, 8 | }: { 9 | day: string; 10 | isSelected: boolean; 11 | className?: string; 12 | }): React.ReactElement => { 13 | const baseClasses = 14 | "flex h-10 w-10 items-center justify-center rounded-full m-1 border border-black text-xl"; 15 | 16 | const selectedClasses = isSelected 17 | ? "bg-northeastern-red text-white" 18 | : "bg-white text-black"; 19 | 20 | return ( 21 |
22 | {day} 23 |
24 | ); 25 | }; 26 | export default StaticDayBox; 27 | -------------------------------------------------------------------------------- /src/pages/api/geocoding.ts: -------------------------------------------------------------------------------- 1 | import { NextApiRequest, NextApiResponse } from "next"; 2 | import { serverEnv } from "../../utils/env/server"; 3 | 4 | const handler = async (req: NextApiRequest, res: NextApiResponse) => { 5 | try { 6 | const endpoint = `https://api.mapbox.com/geocoding/v5/mapbox.places/${req.body.value}.json?access_token=${serverEnv.NEXT_PUBLIC_MAPBOX_ACCESS_TOKEN}&autocomplete=${req.body.autocomplete}&country=${req.body.country}&proximity=${req.body.proximity}&types=${req.body.types}`; 7 | const data = await fetch(endpoint).then((response) => response.json()); 8 | return res.status(200).json(data); 9 | } catch (e) { 10 | return res.status(500).json({ error: "Geocoding Unexpected error." }); 11 | } 12 | }; 13 | 14 | export default handler; 15 | -------------------------------------------------------------------------------- /prisma/migrations/20221016165304_added_user_info/migration.sql: -------------------------------------------------------------------------------- 1 | /* 2 | Warnings: 3 | 4 | - Added the required column `end_time` to the `user` table without a default value. This is not possible if the table is not empty. 5 | - Added the required column `start_time` to the `user` table without a default value. This is not possible if the table is not empty. 6 | 7 | */ 8 | -- AlterTable 9 | ALTER TABLE `user` ADD COLUMN `bio` VARCHAR(191) NOT NULL DEFAULT '', 10 | ADD COLUMN `days_working` VARCHAR(191) NOT NULL DEFAULT '', 11 | ADD COLUMN `end_time` TIME(0) NOT NULL, 12 | ADD COLUMN `pronouns` VARCHAR(191) NOT NULL DEFAULT '', 13 | ADD COLUMN `start_coord_lat` DOUBLE NOT NULL DEFAULT 0, 14 | ADD COLUMN `start_coord_lng` DOUBLE NOT NULL DEFAULT 0, 15 | ADD COLUMN `start_time` TIME(0) NOT NULL; 16 | -------------------------------------------------------------------------------- /prisma/migrations/20240910182030_conversationmodel/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateTable 2 | CREATE TABLE `conversation` ( 3 | `id` VARCHAR(191) NOT NULL, 4 | `requestId` VARCHAR(191) NOT NULL, 5 | 6 | UNIQUE INDEX `conversation_requestId_key`(`requestId`), 7 | PRIMARY KEY (`id`) 8 | ) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; 9 | 10 | -- CreateTable 11 | CREATE TABLE `message` ( 12 | `id` VARCHAR(191) NOT NULL, 13 | `conversationId` VARCHAR(191) NOT NULL, 14 | `content` VARCHAR(255) NOT NULL, 15 | `isRead` BOOLEAN NOT NULL DEFAULT false, 16 | `userId` VARCHAR(191) NULL, 17 | 18 | INDEX `message_conversationId_idx`(`conversationId`), 19 | INDEX `message_userId_idx`(`userId`), 20 | PRIMARY KEY (`id`) 21 | ) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; 22 | -------------------------------------------------------------------------------- /src/styles/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | #__next { 6 | width: 100vw; 7 | height: 100vh; 8 | } 9 | 10 | .custom-marker-popup .mapboxgl-popup-content { 11 | background-color: rgb(220, 220, 220, 0.7); 12 | padding: 3px; 13 | pointer-events: none; 14 | } 15 | 16 | .custom-marker-popup .mapboxgl-popup-tip { 17 | border: 0px; 18 | } 19 | 20 | .ant-picker-input > input { 21 | font-family: "Montserrat", sans-serif !important; 22 | font-weight: 500 !important; 23 | font-size: 16px !important; 24 | } 25 | 26 | input[type="month"]::-webkit-calendar-picker-indicator { 27 | opacity: 0; 28 | cursor: pointer; 29 | } 30 | .placeholder:empty:before { 31 | content: "Type a message..."; 32 | color: gray; 33 | opacity: 0.6; 34 | pointer-events: none; 35 | } 36 | -------------------------------------------------------------------------------- /src/utils/profile/useUploadFile.ts: -------------------------------------------------------------------------------- 1 | import { trpc } from "../trpc"; 2 | 3 | export const useUploadFile = (selectedFile: File | null) => { 4 | const { data: presignedData, error } = trpc.user.getPresignedUrl.useQuery( 5 | { 6 | contentType: selectedFile?.type || "", 7 | }, 8 | { enabled: !!selectedFile } 9 | ); 10 | const uploadFile = async () => { 11 | if (presignedData?.url && selectedFile) { 12 | const url = presignedData.url; 13 | 14 | const response = await fetch(url, { 15 | method: "PUT", 16 | headers: { 17 | "Content-Type": selectedFile.type, 18 | }, 19 | body: selectedFile, 20 | }); 21 | 22 | if (!response.ok) { 23 | throw new Error(`Failed to upload file: ${response.statusText}`); 24 | } 25 | } 26 | }; 27 | 28 | return { uploadFile, error }; 29 | }; 30 | -------------------------------------------------------------------------------- /src/utils/useAddressSelection.ts: -------------------------------------------------------------------------------- 1 | import { useState, useMemo } from "react"; 2 | import { debounce } from "lodash"; 3 | import useSearch from "../utils/search"; 4 | import { CarpoolAddress, CarpoolFeature } from "./types"; 5 | 6 | export const useAddressSelection = ( 7 | initialAddress: CarpoolAddress = { place_name: "", center: [0, 0] } 8 | ) => { 9 | const [selectedAddress, setSelectedAddress] = 10 | useState(initialAddress); 11 | const [address, setAddress] = useState(""); 12 | const [suggestions, setSuggestions] = useState([]); 13 | 14 | const updateAddress = useMemo(() => debounce(setAddress, 250), []); 15 | 16 | useSearch({ 17 | value: address, 18 | type: "address%2Cpostcode", 19 | setFunc: setSuggestions, 20 | }); 21 | 22 | return { selectedAddress, setSelectedAddress, updateAddress, suggestions }; 23 | }; 24 | -------------------------------------------------------------------------------- /src/utils/dateUtils.ts: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { UseFormSetValue } from "react-hook-form"; 3 | import { OnboardingFormInputs } from "./types"; 4 | 5 | const handleMonthChange = 6 | ( 7 | field: "coopStartDate" | "coopEndDate", 8 | setValue: UseFormSetValue 9 | ) => 10 | (event: React.ChangeEvent): void => { 11 | const [year, month] = event.target.value.split("-").map(Number); 12 | const lastDay = new Date(year, month, 0); 13 | setValue(field, lastDay, { shouldValidate: true }); 14 | }; 15 | 16 | const formatDateToMonth = (date: Date | null): string | undefined => { 17 | if (!date) { 18 | return undefined; 19 | } 20 | const year = date.getFullYear(); 21 | const month = String(date.getMonth() + 1).padStart(2, "0"); 22 | return `${year}-${month}`; 23 | }; 24 | 25 | export { handleMonthChange, formatDateToMonth }; 26 | -------------------------------------------------------------------------------- /src/pages/_app.tsx: -------------------------------------------------------------------------------- 1 | import "../styles/globals.css"; 2 | import type { AppProps } from "next/app"; 3 | import { Session } from "next-auth"; 4 | import { ToastContainer } from "react-toastify"; 5 | import "react-toastify/dist/ReactToastify.css"; 6 | import { SessionProvider } from "next-auth/react"; 7 | import Head from "next/head"; 8 | import { trpc } from "../utils/trpc"; 9 | 10 | export function MyApp({ 11 | Component, 12 | pageProps: { session, ...pageProps }, 13 | }: AppProps<{ session: Session }>) { 14 | return ( 15 | <> 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | ); 26 | } 27 | 28 | export default trpc.withTRPC(MyApp); 29 | -------------------------------------------------------------------------------- /src/components/Spinner.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | const Spinner: React.FC = () => { 4 | return ( 5 |
6 | 12 | 20 | 25 | 26 | Loading... 27 |
28 | ); 29 | }; 30 | 31 | export default Spinner; 32 | -------------------------------------------------------------------------------- /prisma/migrations/20230414004248_groups_invitations/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateTable 2 | CREATE TABLE `invitation` ( 3 | `id` VARCHAR(191) NOT NULL, 4 | `message` VARCHAR(191) NOT NULL, 5 | `fromUserId` VARCHAR(191) NOT NULL, 6 | `toUserId` VARCHAR(191) NOT NULL, 7 | 8 | INDEX `invitation_fromUserId_idx`(`fromUserId`), 9 | INDEX `invitation_toUserId_idx`(`toUserId`), 10 | PRIMARY KEY (`id`) 11 | ) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; 12 | 13 | -- CreateTable 14 | CREATE TABLE `group` ( 15 | `id` INTEGER NOT NULL AUTO_INCREMENT, 16 | `name` VARCHAR(191) NOT NULL, 17 | 18 | PRIMARY KEY (`id`) 19 | ) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; 20 | 21 | -- CreateTable 22 | CREATE TABLE `_userCarpools` ( 23 | `A` INTEGER NOT NULL, 24 | `B` VARCHAR(191) NOT NULL, 25 | 26 | UNIQUE INDEX `_userCarpools_AB_unique`(`A`, `B`), 27 | INDEX `_userCarpools_B_index`(`B`) 28 | ) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; 29 | -------------------------------------------------------------------------------- /src/components/Admin/AdminSidebar.tsx: -------------------------------------------------------------------------------- 1 | type AdminSidebarProps = { 2 | option: string; 3 | setOption: React.Dispatch>; 4 | }; 5 | 6 | const AdminSidebar = ({ option, setOption }: AdminSidebarProps) => { 7 | const baseButton = "px-4 py-2 text-northeastern-red font-montserrat text-xl "; 8 | const selectedButton = " font-bold underline underline-offset-8 "; 9 | return ( 10 |
11 |
12 | 18 | 24 |
25 |
26 | ); 27 | }; 28 | export default AdminSidebar; 29 | -------------------------------------------------------------------------------- /src/components/EntryLabel.tsx: -------------------------------------------------------------------------------- 1 | import { FieldError } from "react-hook-form"; 2 | import styled from "styled-components"; 3 | 4 | interface EntryLabelProps { 5 | error?: FieldError | (FieldError | undefined)[]; 6 | label: string; 7 | required?: boolean; 8 | className?: string; 9 | } 10 | 11 | const StyledLabel = styled.label<{ 12 | error?: boolean; 13 | }>` 14 | font-family: "Montserrat", sans-serif; 15 | font-style: normal; 16 | font-weight: 700; 17 | font-size: 20px; 18 | line-height: 24px; 19 | display: flex; 20 | align-items: center; 21 | color: ${(props) => (props.error ? "#B12424" : "#000000")}; 22 | 23 | @media (min-width: 834px) { 24 | padding-top: 0.3rem; 25 | padding-bottom: 0.4rem; 26 | font-size: 20px; 27 | } 28 | `; 29 | 30 | export const EntryLabel = (props: EntryLabelProps) => { 31 | return props.required ? ( 32 | 33 | {props.label} 34 | * 35 | 36 | ) : ( 37 | {props.label} 38 | ); 39 | }; 40 | -------------------------------------------------------------------------------- /src/server/router/createRouter.ts: -------------------------------------------------------------------------------- 1 | import { TRPCError, initTRPC } from "@trpc/server"; 2 | import { Context } from "./context"; 3 | import superjson from "superjson"; 4 | 5 | const t = initTRPC.context().create({ 6 | transformer: superjson, 7 | }); 8 | 9 | export const router = t.router; 10 | export const procedure = t.procedure; 11 | export const middleware = t.middleware; 12 | 13 | const isProtected = middleware(({ ctx, next }) => { 14 | if (!ctx.session) { 15 | throw new TRPCError({ code: "UNAUTHORIZED" }); 16 | } 17 | 18 | return next({ 19 | ctx: { 20 | ...ctx, 21 | session: ctx.session, 22 | }, 23 | }); 24 | }); 25 | const isAdmin = middleware(({ ctx, next }) => { 26 | if (!ctx.session || !ctx.session.user) { 27 | throw new TRPCError({ code: "UNAUTHORIZED" }); 28 | } 29 | if (ctx.session.user.permission === "USER") { 30 | throw new TRPCError({ code: "UNAUTHORIZED" }); 31 | } 32 | 33 | return next({ 34 | ctx: { 35 | ...ctx, 36 | session: ctx.session, 37 | }, 38 | }); 39 | }); 40 | 41 | export const protectedRouter = procedure.use(isProtected); 42 | export const adminRouter = procedure.use(isAdmin); 43 | -------------------------------------------------------------------------------- /src/server/router/context.ts: -------------------------------------------------------------------------------- 1 | import * as trpc from "@trpc/server"; 2 | import * as trpcNext from "@trpc/server/adapters/next"; 3 | import { getServerSession } from "next-auth"; 4 | 5 | import { authOptions as nextAuthOptions } from "../../pages/api/auth/[...nextauth]"; 6 | import { prisma } from "../db/client"; 7 | import { SESClient } from "@aws-sdk/client-ses"; 8 | import { fromEnv } from "@aws-sdk/credential-provider-env"; 9 | import { serverEnv } from "../../utils/env/server"; 10 | 11 | export const createContext = async ( 12 | opts?: trpcNext.CreateNextContextOptions 13 | ) => { 14 | const req = opts?.req; 15 | const res = opts?.res; 16 | 17 | const sesClient = new SESClient({ 18 | region: serverEnv.AWS_REGION, 19 | credentials: { 20 | accessKeyId: serverEnv.AWS_ACCESS_KEY_ID, 21 | secretAccessKey: serverEnv.AWS_SECRET_ACCESS_KEY, 22 | }, 23 | }); 24 | 25 | const session = 26 | req && res && (await getServerSession(req, res, nextAuthOptions)); 27 | 28 | return { 29 | req, 30 | res, 31 | session, 32 | prisma, 33 | sesClient, 34 | }; 35 | }; 36 | 37 | export type Context = trpc.inferAsyncReturnType; 38 | -------------------------------------------------------------------------------- /src/utils/latestMessage.ts: -------------------------------------------------------------------------------- 1 | import { Message, Request } from "./types"; 2 | 3 | export const getLatestMessageForRequest = ( 4 | request: Request, 5 | currentUserId: string 6 | ): Message | null => { 7 | if (!request) return null; 8 | 9 | const initialMessage: Message = { 10 | conversationId: "", 11 | id: `request-${request.id}`, 12 | content: request.message, 13 | userId: request.fromUserId, 14 | dateCreated: request.dateCreated || new Date(), 15 | isRead: request.fromUserId === currentUserId, 16 | }; 17 | 18 | const conversationMessages: Message[] = request.conversation?.messages || []; 19 | 20 | const allMessages = [initialMessage, ...conversationMessages]; 21 | 22 | allMessages.sort( 23 | (a, b) => 24 | new Date(b.dateCreated).getTime() - new Date(a.dateCreated).getTime() 25 | ); 26 | 27 | return allMessages[0]; 28 | }; 29 | export const getCardSortingData = ( 30 | userId: string, 31 | request: Request, 32 | latestMessage: Message | null 33 | ) => { 34 | const isUnread = latestMessage 35 | ? !latestMessage.isRead && userId !== latestMessage.userId 36 | : false; 37 | const latestActivityDate = latestMessage 38 | ? latestMessage.dateCreated 39 | : request.dateCreated; 40 | 41 | return { isUnread, latestActivityDate }; 42 | }; 43 | -------------------------------------------------------------------------------- /src/components/Admin/AdminData.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from "react"; 2 | import Spinner from "../Spinner"; 3 | import { trpc } from "../../utils/trpc"; 4 | import { TempUser, TempGroup } from "../../utils/types"; 5 | import BarChartUserCounts from "./BarChartUserCounts"; 6 | import LineChartCount from "./LineChartCount"; 7 | 8 | function AdminData() { 9 | const [loading, setLoading] = useState(true); 10 | const { data: users = [] } = 11 | trpc.user.admin.getAllUsers.useQuery(); 12 | const { data: groups = [] } = 13 | trpc.user.admin.getCarpoolGroups.useQuery(); 14 | 15 | useEffect(() => { 16 | if (users && groups) { 17 | setLoading(false); 18 | } 19 | }, [users, groups]); 20 | 21 | if (loading) { 22 | return ; 23 | } 24 | 25 | return ( 26 |
27 |
28 |
29 | 30 |
31 |
32 | 33 |
34 |
35 |
36 | ); 37 | } 38 | 39 | export default AdminData; 40 | -------------------------------------------------------------------------------- /src/utils/env/server.ts: -------------------------------------------------------------------------------- 1 | import { envsafe, str, url, makeValidator, invalidEnvError } from "envsafe"; 2 | import { browserEnv } from "./browser"; 3 | 4 | if (typeof window !== "undefined") { 5 | throw new Error( 6 | "This should only be included on the client (but the env vars wont be exposed)" 7 | ); 8 | } 9 | 10 | export const serverEnv = { 11 | ...browserEnv, 12 | ...envsafe({ 13 | DATABASE_URL: str({ 14 | input: process.env.DATABASE_URL, 15 | }), 16 | NEXTAUTH_SECRET: str({ 17 | input: process.env.NEXTAUTH_SECRET, 18 | devDefault: "xxx", 19 | }), 20 | AWS_ACCESS_KEY_ID: str({ 21 | input: process.env.ACCESS_KEY_ID_AWS, 22 | }), 23 | AWS_SECRET_ACCESS_KEY: str({ 24 | input: process.env.SECRET_ACCESS_KEY_AWS, 25 | }), 26 | AWS_REGION: str({ 27 | input: process.env.REGION_AWS, 28 | }), 29 | AZURE_CLIENT_ID: str({ 30 | input: process.env.AZURE_CLIENT_ID, 31 | }), 32 | AZURE_CLIENT_SECRET: str({ 33 | input: process.env.AZURE_CLIENT_SECRET, 34 | }), 35 | AZURE_TENANT_ID: str({ 36 | input: process.env.AZURE_TENANT_ID, 37 | }), 38 | GOOGLE_CLIENT_ID: str({ 39 | input: process.env.GOOGLE_CLIENT_ID, 40 | }), 41 | GOOGLE_CLIENT_SECRET: str({ 42 | input: process.env.GOOGLE_CLIENT_SECRET, 43 | }), 44 | }), 45 | }; 46 | -------------------------------------------------------------------------------- /src/utils/map/updateUserLocation.ts: -------------------------------------------------------------------------------- 1 | import { Map } from "mapbox-gl"; 2 | import PulsingDot from "./PulsingDot"; 3 | 4 | const updateUserLocation = ( 5 | map: Map, 6 | userLongitude: number, 7 | userLatitude: number 8 | ) => { 9 | if (map.getSource("dot-point")) { 10 | const source = map.getSource("dot-point") as mapboxgl.GeoJSONSource; 11 | // Update the data for dot-point user location 12 | source.setData({ 13 | type: "Feature", 14 | geometry: { 15 | type: "Point", 16 | coordinates: [userLongitude, userLatitude], 17 | }, 18 | properties: {}, 19 | }); 20 | } else { 21 | map.addSource("dot-point", { 22 | type: "geojson", 23 | data: { 24 | type: "Feature", 25 | geometry: { 26 | type: "Point", 27 | coordinates: [userLongitude, userLatitude], // icon position [lng, lat] 28 | }, 29 | properties: {}, 30 | }, 31 | }); 32 | map.addImage("pulsing-dot", new PulsingDot(100, map), { 33 | pixelRatio: 2, 34 | }); 35 | map.addLayer({ 36 | id: "layer-with-pulsing-dot", 37 | type: "symbol", 38 | source: "dot-point", 39 | layout: { 40 | "icon-image": "pulsing-dot", 41 | "icon-allow-overlap": true, 42 | }, 43 | }); 44 | } 45 | }; 46 | 47 | export default updateUserLocation; 48 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # NU Carpool 2 | 3 | This is a web app for Northeastern University's students to assists them in finding groups for carpooling while on co-op. 4 | 5 | ## Get Started 6 | 7 | - Clone the project, add environment variables (listed below) in `.env`. 8 | 9 | ```env 10 | # Prisma 11 | 12 | 13 | # DATABASE_URL = 14 | 15 | # Next Auth 16 | 17 | NEXTAUTH_SECRET= 18 | NEXTAUTH_URL= 19 | 20 | #MixPanel (use any value for local development) 21 | NEXT_PUBLIC_MIXPANEL_PROJECT_TOKEN= 22 | # Mapbox 23 | 24 | NEXT_PUBLIC_MAPBOX_ACCESS_TOKEN= 25 | 26 | # AWS 27 | ACCESS_KEY_ID_AWS= 28 | SECRET_ACCESS_KEY_AWS= 29 | REGION_AWS= 30 | 31 | #Azure Provider (Can be switched out for other Auth Providers) 32 | AZURE_CLIENT_ID= 33 | AZURE_CLIENT_SECRET= 34 | AZURE_TENANT_ID= 35 | 36 | # Google Auth Provider (used in the non-prod environment) 37 | GOOGLE_CLIENT_ID= 38 | GOOGLE_CLIENT_SECRET= 39 | 40 | # Environment Configuration 41 | BUILD_ENV= 42 | NEXT_PUBLIC_ENV= 43 | ``` 44 | 45 | Then do `yarn` and `yarn dev` to get the project running. 46 | 47 | ## Tech Stack 48 | 49 | - Framework: NextJS + Typescript 50 | - Component Library: TailwindCSS + Headless UI 51 | - Authentication: NextAuth 52 | - Map API: Mapbox 53 | - Backend: Serverless with trpc + Prisma + mysql (hosted on PlanetScale) 54 | 55 | This is also known as the T3 Stack. More details can be found [here](https://init.tips). 56 | -------------------------------------------------------------------------------- /src/utils/adminDataUtils.ts: -------------------------------------------------------------------------------- 1 | import { addWeeks, startOfWeek } from "date-fns"; 2 | 3 | interface ItemWithDate { 4 | dateCreated: Date; 5 | } 6 | export const filterItemsByDate = ( 7 | items: ItemWithDate[], 8 | startTimestamp: number, 9 | endTimestamp: number 10 | ) => { 11 | return items.filter((item) => { 12 | const itemTimestamp = startOfWeek(item.dateCreated).getTime(); 13 | return itemTimestamp >= startTimestamp && itemTimestamp <= endTimestamp; 14 | }); 15 | }; 16 | export const countCumulativeItemsPerWeek = ( 17 | items: ItemWithDate[], 18 | weekLabels: Date[] 19 | ): (number | null)[] => { 20 | const counts: (number | null)[] = []; 21 | let cumulativeCount = 0; 22 | let prevCount = 0; 23 | let itemIndex = 0; 24 | const sortedItems = items 25 | .slice() 26 | .sort( 27 | (a, b) => 28 | new Date(a.dateCreated).getTime() - new Date(b.dateCreated).getTime() 29 | ); 30 | 31 | weekLabels.forEach((weekStart, index) => { 32 | const weekEnd = addWeeks(weekStart, 1); 33 | while ( 34 | itemIndex < sortedItems.length && 35 | new Date(sortedItems[itemIndex].dateCreated) < weekEnd 36 | ) { 37 | cumulativeCount++; 38 | itemIndex++; 39 | } 40 | 41 | if (index === 0 || cumulativeCount > prevCount) { 42 | counts.push(cumulativeCount); 43 | } else { 44 | counts.push(null); 45 | } 46 | prevCount = cumulativeCount; 47 | }); 48 | 49 | return counts; 50 | }; 51 | -------------------------------------------------------------------------------- /src/utils/cropImage.ts: -------------------------------------------------------------------------------- 1 | export default function getCroppedImg( 2 | imageSrc: string, 3 | croppedAreaPixels: any 4 | ) { 5 | return new Promise<{ file: File; url: string }>((resolve, reject) => { 6 | const image = new Image(); 7 | image.src = imageSrc; 8 | image.onload = () => { 9 | const canvas = document.createElement("canvas"); 10 | const ctx = canvas.getContext("2d"); 11 | 12 | if (!ctx) { 13 | return reject(new Error("Failed to get canvas context")); 14 | } 15 | 16 | canvas.width = croppedAreaPixels.width; 17 | canvas.height = croppedAreaPixels.height; 18 | 19 | ctx.drawImage( 20 | image, 21 | croppedAreaPixels.x, 22 | croppedAreaPixels.y, 23 | croppedAreaPixels.width, 24 | croppedAreaPixels.height, 25 | 0, 26 | 0, 27 | croppedAreaPixels.width, 28 | croppedAreaPixels.height 29 | ); 30 | 31 | canvas.toBlob( 32 | (blob) => { 33 | if (blob) { 34 | const file = new File([blob], "cropped-image.jpeg", { 35 | type: "image/jpeg", 36 | }); 37 | const url = URL.createObjectURL(blob); 38 | resolve({ file, url }); 39 | } else { 40 | reject(new Error("Canvas is empty")); 41 | } 42 | }, 43 | "image/jpeg", 44 | 0.7 45 | ); 46 | }; 47 | 48 | image.onerror = () => { 49 | reject(new Error("Failed to load image")); 50 | }; 51 | }); 52 | } 53 | -------------------------------------------------------------------------------- /src/components/Map/InactiveBlocker.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from "react"; 2 | import { useRouter } from "next/router"; 3 | import Spinner from "../Spinner"; 4 | 5 | const InactiveBlocker = () => { 6 | const [isLoading, setIsLoading] = useState(false); 7 | const router = useRouter(); 8 | 9 | const handleProfileClick = async () => { 10 | setIsLoading(true); 11 | await router.push("/profile"); 12 | setIsLoading(false); 13 | }; 14 | 15 | return ( 16 |
17 | {isLoading && ( 18 |
19 | 20 |
21 | )} 22 |
27 |

You are currently inactive

28 |

29 | To view and interact with the map, please change your activity status 30 | in your profile. 31 |

32 | 38 |
39 |
40 | ); 41 | }; 42 | 43 | export default InactiveBlocker; 44 | -------------------------------------------------------------------------------- /src/utils/mixpanel.ts: -------------------------------------------------------------------------------- 1 | import mixpanel from "mixpanel-browser"; 2 | import { browserEnv } from "./env/browser"; 3 | 4 | const mixpanelToken = browserEnv.NEXT_PUBLIC_MIXPANEL_PROJECT_TOKEN; 5 | 6 | if (!mixpanelToken) { 7 | throw new Error("NEXT_PUBLIC_MIXPANEL_PROJECT_TOKEN is not defined"); 8 | } 9 | 10 | mixpanel.init(mixpanelToken, { 11 | debug: process.env.NODE_ENV !== "production", 12 | track_pageview: true, 13 | persistence: "localStorage", 14 | }); 15 | 16 | export const trackEvent = ( 17 | eventName: string, 18 | properties?: Record 19 | ) => { 20 | mixpanel.track(eventName, properties); 21 | }; 22 | 23 | export const setUserProperties = (properties: Record) => { 24 | mixpanel.people.set(properties); 25 | }; 26 | export const trackFTUECompletion = (role: string) => { 27 | trackEvent("FTUE Completed", { role }); 28 | }; 29 | export const trackFTUEStep = (step: number) => { 30 | const name = "FTUE Step " + step; 31 | trackEvent(name); 32 | }; 33 | // Add this new function 34 | export const trackProfileCompletion = (role: string, status: string) => { 35 | trackEvent("Profile Completed", { role, status }); 36 | }; 37 | 38 | // Add this new function 39 | export const trackViewRoute = () => { 40 | trackEvent("View Route Clicked", { 41 | timestamp: new Date().toISOString(), 42 | }); 43 | }; 44 | 45 | export const trackRequestResponse = (action: "accept" | "decline") => { 46 | trackEvent("Request Response", { 47 | action, 48 | timestamp: new Date().toISOString(), 49 | }); 50 | }; 51 | -------------------------------------------------------------------------------- /src/components/Setup/FormRadioButton.tsx: -------------------------------------------------------------------------------- 1 | import { ComponentPropsWithoutRef } from "react"; 2 | import React, { useState, useEffect } from "react"; 3 | import { FieldError } from "react-hook-form"; 4 | 5 | type RadioProps = { 6 | label: string; 7 | value: T; 8 | id: string; 9 | currentlySelected: T; 10 | error?: FieldError; 11 | className?: string; 12 | } & React.ComponentPropsWithoutRef<"input">; 13 | 14 | const RadioButton = React.forwardRef< 15 | HTMLInputElement, 16 | RadioProps 17 | >( 18 | ( 19 | { label, id, value, currentlySelected, error, className, ...rest }, 20 | forwardedRef 21 | ) => { 22 | return ( 23 | 44 | ); 45 | } 46 | ); 47 | 48 | RadioButton.displayName = "RadioButton"; 49 | export default RadioButton; 50 | -------------------------------------------------------------------------------- /src/components/Profile/UnsavedModal.tsx: -------------------------------------------------------------------------------- 1 | type UnsavedModalProps = { 2 | onClose: () => void; 3 | onSave: () => void; 4 | onContinue: () => void; 5 | }; 6 | 7 | function UnsavedModal({ onClose, onSave, onContinue }: UnsavedModalProps) { 8 | return ( 9 |
10 |
11 | 17 |

18 | You have unsaved changes! 19 |

20 |

Continue with or without saving?

21 |
22 | 28 | 35 |
36 |
37 |
38 | ); 39 | } 40 | export default UnsavedModal; 41 | -------------------------------------------------------------------------------- /src/utils/search.ts: -------------------------------------------------------------------------------- 1 | import { Dispatch, SetStateAction, useEffect } from "react"; 2 | import { toast } from "react-toastify"; 3 | import { trpc } from "./trpc"; 4 | import { CarpoolFeature } from "./types"; 5 | 6 | /** 7 | * Listens to updates from `value` - on updates, new queries are sent to Mapbox search, with customization 8 | * of `type` as well as a function to handle the return values. 9 | * 10 | * @param value the search value to "listen" to 11 | * @param type the type of the object sent to the query, either "address%2Cpostcode" or "neighborhood%2Cplace" 12 | * @param setFunc the function which will be called to update with new features 13 | */ 14 | export default function useSearch({ 15 | value, 16 | type, 17 | setFunc, 18 | }: { 19 | value: string; 20 | type: "address%2Cpostcode" | "neighborhood%2Cplace"; 21 | setFunc: Dispatch>; 22 | }) { 23 | const query = trpc.mapbox.search.useQuery( 24 | { 25 | value: value, 26 | types: type, 27 | proximity: "ip", 28 | country: "us", 29 | autocomplete: true, 30 | }, 31 | { 32 | onSuccess: (data) => { 33 | /* the standard Feature type does not describe the full breadth of properties 34 | available such as "place_name" and "center" */ 35 | setFunc((data?.features || []) as CarpoolFeature[]); 36 | }, 37 | onError: (error) => { 38 | toast.error(`Something went wrong: ${error}`); 39 | }, 40 | enabled: false, 41 | } 42 | ); 43 | useEffect(() => { 44 | if (value) { 45 | query.refetch(); 46 | } 47 | }, [value]); 48 | } 49 | -------------------------------------------------------------------------------- /src/utils/useProfileImage.ts: -------------------------------------------------------------------------------- 1 | import { useState, useEffect, useCallback } from "react"; 2 | import { trpc } from "./trpc"; 3 | 4 | const useProfileImage = (userId?: string) => { 5 | const [profileImageUrl, setProfileImageUrl] = useState(null); 6 | const [imageLoadError, setImageLoadError] = useState(false); 7 | const [hasRefetched, setHasRefetched] = useState(false); 8 | 9 | const { 10 | data: presignedData, 11 | error: presignedError, 12 | refetch, 13 | } = trpc.user.getPresignedDownloadUrl.useQuery( 14 | { userId }, 15 | { enabled: !profileImageUrl } 16 | ); 17 | useEffect(() => { 18 | setProfileImageUrl(null); 19 | setImageLoadError(false); 20 | setHasRefetched(false); 21 | }, [userId]); 22 | const fetchImageUrl = useCallback(() => { 23 | if (presignedData?.url && !presignedError) { 24 | setProfileImageUrl(presignedData.url); 25 | setImageLoadError(false); 26 | } else { 27 | setImageLoadError(true); 28 | setProfileImageUrl(null); 29 | } 30 | }, [presignedData, presignedError]); 31 | 32 | useEffect(() => { 33 | fetchImageUrl(); 34 | }, [fetchImageUrl]); 35 | 36 | useEffect(() => { 37 | if (!hasRefetched) { 38 | const timer = setTimeout(() => { 39 | refetch() 40 | .then(() => setHasRefetched(true)) 41 | .catch((error) => { 42 | console.error("Error refetching profile image:", error); 43 | }); 44 | }, 600); 45 | 46 | return () => clearTimeout(timer); 47 | } 48 | }, [refetch, hasRefetched]); 49 | 50 | return { profileImageUrl, imageLoadError }; 51 | }; 52 | 53 | export default useProfileImage; 54 | -------------------------------------------------------------------------------- /src/server/router/user/favorites.ts: -------------------------------------------------------------------------------- 1 | import { TRPCError } from "@trpc/server"; 2 | import { z } from "zod"; 3 | import { router, protectedRouter } from "../createRouter"; 4 | import _ from "lodash"; 5 | import { convertToPublic } from "../../../utils/publicUser"; 6 | import { Status } from "@prisma/client"; 7 | 8 | export const favoritesRouter = router({ 9 | me: protectedRouter.query(async ({ ctx }) => { 10 | const id = ctx.session.user?.id; 11 | const user = await ctx.prisma.user.findUnique({ 12 | where: { id }, 13 | select: { 14 | role: true, 15 | favorites: true, 16 | }, 17 | }); 18 | 19 | // throws TRPCError if no user with ID exists 20 | if (!user) { 21 | throw new TRPCError({ 22 | code: "NOT_FOUND", 23 | message: `No profile with id '${id}'`, 24 | }); 25 | } 26 | const userRole = user.role; 27 | const filteredFavorites = user.favorites.filter( 28 | (favorite) => 29 | favorite.role !== userRole && 30 | favorite.role !== "VIEWER" && 31 | favorite.status !== Status.INACTIVE 32 | ); 33 | return filteredFavorites.map(convertToPublic); 34 | }), 35 | edit: protectedRouter 36 | .input( 37 | z.object({ 38 | userId: z.string(), 39 | favoriteId: z.string(), 40 | add: z.boolean(), 41 | }) 42 | ) 43 | .mutation(async ({ ctx, input }) => { 44 | await ctx.prisma.user.update({ 45 | where: { 46 | id: input.userId, 47 | }, 48 | data: { 49 | favorites: { 50 | [input.add ? "connect" : "disconnect"]: { id: input.favoriteId }, 51 | }, 52 | }, 53 | }); 54 | }), 55 | }); 56 | -------------------------------------------------------------------------------- /src/utils/trpc.ts: -------------------------------------------------------------------------------- 1 | import { httpBatchLink, loggerLink } from "@trpc/client"; 2 | import { createTRPCNext } from "@trpc/next"; 3 | import type { AppRouter } from "../server/router"; 4 | import superjson from "superjson"; 5 | import { TRPCError } from "@trpc/server"; 6 | 7 | const getBaseUrl = () => { 8 | if (typeof window !== "undefined") { 9 | return ""; 10 | } 11 | if (process.env.VERCEL_URL) return `https://${process.env.VERCEL_URL}`; // SSR should use vercel url 12 | 13 | return `http://localhost:${process.env.PORT ?? 3000}`; // dev SSR should use localhost 14 | }; 15 | 16 | export const trpc = createTRPCNext({ 17 | config(opts) { 18 | return { 19 | links: [ 20 | loggerLink({ 21 | enabled: (opts) => 22 | process.env.NODE_ENV === "development" || 23 | (opts.direction === "down" && opts.result instanceof Error), 24 | }), 25 | httpBatchLink({ 26 | url: `${getBaseUrl()}/api/trpc`, 27 | }), 28 | ], 29 | transformer: superjson, 30 | queryClientConfig: { 31 | defaultOptions: { 32 | queries: { 33 | retry: (failureCount: number, error: any) => { 34 | const trcpErrorCode = error?.data?.code as TRPCError["code"]; 35 | if (trcpErrorCode === "NOT_FOUND") { 36 | return false; 37 | } 38 | if (failureCount < 3) { 39 | return true; 40 | } 41 | return false; 42 | }, 43 | refetchOnMount: false, 44 | refetchOnWindowFocus: false, 45 | }, 46 | }, 47 | }, 48 | }; 49 | }, 50 | /** 51 | * @link https://trpc.io/docs/ssr 52 | **/ 53 | ssr: false, 54 | }); 55 | -------------------------------------------------------------------------------- /src/components/UserCards/ReceivedCard.tsx: -------------------------------------------------------------------------------- 1 | import { EnhancedPublicUser, PublicUser, User } from "../../utils/types"; 2 | import { UserCard } from "./UserCard"; 3 | import { useContext, useState } from "react"; 4 | import { UserContext } from "../../utils/userContext"; 5 | import { createPortal } from "react-dom"; 6 | import ReceivedRequestModal from "../Modals/ReceivedRequestModal"; 7 | import { Message } from "../../utils/types"; 8 | 9 | interface ReceivedCardProps { 10 | otherUser: EnhancedPublicUser; 11 | onViewRouteClick: (user: User, otherUser: PublicUser) => void; 12 | onClick: () => void; 13 | selectedUser: EnhancedPublicUser | null; 14 | isUnread: boolean; 15 | latestMessage?: Message; 16 | } 17 | export const ReceivedCard = (props: ReceivedCardProps): JSX.Element => { 18 | const user = useContext(UserContext); 19 | const [showModal, setShowModal] = useState(false); 20 | 21 | 22 | return ( 23 | <> 24 |
25 | 35 |
36 | {showModal && 37 | user && 38 | props.otherUser.incomingRequest && 39 | createPortal( 40 | setShowModal(false)} 44 | req={props.otherUser.incomingRequest} 45 | />, 46 | document.body 47 | )} 48 | 49 | ); 50 | }; 51 | -------------------------------------------------------------------------------- /src/components/UserCards/SentCard.tsx: -------------------------------------------------------------------------------- 1 | import { useContext, useState } from "react"; 2 | import { 3 | EnhancedPublicUser, 4 | Message, 5 | PublicUser, 6 | } from "../../utils/types"; 7 | import { UserContext } from "../../utils/userContext"; 8 | import { UserCard } from "./UserCard"; 9 | import SentRequestModal from "../Modals/SentRequestModal"; 10 | import { createPortal } from "react-dom"; 11 | import { User } from "@prisma/client"; 12 | 13 | interface SentCardProps { 14 | otherUser: EnhancedPublicUser; 15 | onViewRouteClick: (user: User, otherUser: PublicUser) => void; 16 | onClick: () => void; 17 | selectedUser: EnhancedPublicUser | null; 18 | isUnread: boolean; 19 | latestMessage?: Message; 20 | } 21 | 22 | export const SentCard = (props: SentCardProps): JSX.Element => { 23 | const user = useContext(UserContext); 24 | const [showModal, setShowModal] = useState(false); 25 | 26 | 27 | return ( 28 | <> 29 |
30 | 40 |
41 | {showModal && 42 | user && 43 | props.otherUser.outgoingRequest && 44 | createPortal( 45 | setShowModal(false)} 49 | req={props.otherUser.outgoingRequest} 50 | />, 51 | document.body 52 | )} 53 | 54 | ); 55 | }; 56 | -------------------------------------------------------------------------------- /src/styles/profile.ts: -------------------------------------------------------------------------------- 1 | import styled from "styled-components"; 2 | 3 | export const ProfileColumn = styled.div` 4 | display: flex; 5 | flex-direction: column; 6 | padding: 0 0.09rem 0 0.09rem; 7 | width: 100%; 8 | flex: 1 1 auto; 9 | gap: 6px; 10 | `; 11 | 12 | styled(ProfileColumn)` 13 | width: 100%; 14 | flex: 1 1 auto; 15 | `; 16 | 17 | styled(ProfileColumn)` 18 | width: 100%; 19 | flex: 1 1 auto; 20 | `; 21 | 22 | styled(ProfileColumn)` 23 | width: 100%; 24 | padding: 0 0 1rem 0; 25 | display: flex; 26 | flex-direction: column; 27 | justify-content: flex-end; 28 | flex: 0 1 auto; 29 | `; 30 | 31 | styled(ProfileColumn)` 32 | width: 100%; 33 | flex: 1 1 auto; 34 | gap: 4px; 35 | @media (min-width: 834px) { 36 | padding-top: 0; 37 | padding-bottom: 0; 38 | } 39 | padding-top: 12px; 40 | padding-bottom: 12px; 41 | `; 42 | 43 | styled(ProfileColumn)` 44 | width: 100%; 45 | flex: 1 1 auto; 46 | gap: 6px; 47 | `; 48 | 49 | export const ProfileHeader = styled.h1` 50 | display: flex; 51 | font-family: "Montserrat", sans-serif; 52 | font-style: normal; 53 | font-weight: 700; 54 | 55 | line-height: 39px; 56 | color: #000000; 57 | 58 | @media (min-width: 420px) { 59 | margin-bottom: 22px; 60 | font-size: 32px; 61 | } 62 | font-size: 24px; 63 | `; 64 | 65 | styled(ProfileHeader)` 66 | margin-bottom: 0; 67 | `; 68 | 69 | export const Note = styled.p<{}>` 70 | font-family: "Montserrat", sans-serif; 71 | font-style: normal; 72 | font-weight: 300; 73 | font-size: 0.75rem; 74 | line-height: 1rem; 75 | color: gray; 76 | `; 77 | 78 | export const ErrorDisplay = styled.span<{}>` 79 | font-family: "Montserrat", sans-serif; 80 | font-style: normal; 81 | max-width: 100%; 82 | font-weight: 400; 83 | font-size: 16px; 84 | line-height: 19px; 85 | color: #b12424; 86 | `; 87 | -------------------------------------------------------------------------------- /src/utils/map/PulsingDot.ts: -------------------------------------------------------------------------------- 1 | import { Map } from "mapbox-gl"; 2 | 3 | export default class PulsingDot { 4 | width: number; 5 | height: number; 6 | data: Uint8ClampedArray; 7 | context: CanvasRenderingContext2D; 8 | map: Map; 9 | 10 | constructor(size: number, map: Map) { 11 | this.width = size; 12 | this.height = size; 13 | this.data = new Uint8ClampedArray(size * size * 4); 14 | const canvas = document.createElement("canvas"); 15 | canvas.width = this.width; 16 | canvas.height = this.height; 17 | this.context = canvas.getContext("2d")!; 18 | this.map = map; 19 | } 20 | 21 | render() { 22 | const duration = 3000; 23 | const t = (performance.now() % duration) / duration; 24 | 25 | const radius = (this.width / 2) * 0.3; 26 | const outerRadius = (this.width / 2) * 0.7 * t + radius; 27 | const context = this.context; 28 | 29 | // Draw the outer circle. 30 | context.clearRect(0, 0, this.width, this.height); 31 | context.beginPath(); 32 | context.arc(this.width / 2, this.height / 2, outerRadius, 0, Math.PI * 2); 33 | context.fillStyle = `rgba(66, 133, 244, ${1 - t})`; 34 | context.fill(); 35 | 36 | // Draw the inner circle. 37 | context.beginPath(); 38 | context.arc(this.width / 2, this.height / 2, radius, 0, Math.PI * 2); 39 | context.fillStyle = "rgba(66, 133, 244, 1)"; 40 | context.strokeStyle = "white"; 41 | context.lineWidth = 2 + 4 * (1 - t); 42 | context.fill(); 43 | context.stroke(); 44 | 45 | // Update this image's data with data from the canvas. 46 | this.data = context.getImageData(0, 0, this.width, this.height).data; 47 | 48 | // Continuously repaint the map, resulting 49 | // in the smooth animation of the dot. 50 | this.map.triggerRepaint(); 51 | 52 | // Return `true` to let the map know that the image was updated. 53 | return true; 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/utils/uploadToS3.ts: -------------------------------------------------------------------------------- 1 | import { 2 | GetObjectCommand, 3 | HeadObjectCommand, 4 | PutObjectCommand, 5 | S3Client, 6 | } from "@aws-sdk/client-s3"; 7 | import { serverEnv } from "./env/server"; 8 | import { getSignedUrl } from "@aws-sdk/s3-request-presigner"; 9 | import { browserEnv } from "./env/browser"; 10 | 11 | // Create an S3 client instance 12 | const s3Client = new S3Client({ 13 | region: "us-east-2", 14 | credentials: { 15 | accessKeyId: serverEnv.AWS_ACCESS_KEY_ID, 16 | secretAccessKey: serverEnv.AWS_SECRET_ACCESS_KEY, 17 | }, 18 | }); 19 | 20 | export async function generatePresignedUrl( 21 | fileName: string, 22 | contentType: string 23 | ) { 24 | const build = process.env.NEXT_PUBLIC_ENV; 25 | const command = new PutObjectCommand({ 26 | Bucket: "carpoolnubucket", 27 | Key: `profile-pictures/${build}/${fileName}`, 28 | ContentType: contentType, 29 | }); 30 | 31 | const expiry = 3600; 32 | 33 | try { 34 | return await getSignedUrl(s3Client, command, { expiresIn: expiry }); 35 | } catch (error) { 36 | console.error("Error generating pre-signed URL for putting", error); 37 | throw new Error("Could not generate pre-signed URL"); 38 | } 39 | } 40 | export async function getPresignedImageUrl(fileName: string) { 41 | const build = process.env.NEXT_PUBLIC_ENV; 42 | const key = `profile-pictures/${build}/${fileName}`; 43 | const expiry = 3600; 44 | 45 | try { 46 | // Check if the object exists 47 | await s3Client.send( 48 | new HeadObjectCommand({ Bucket: "carpoolnubucket", Key: key }) 49 | ); 50 | 51 | // If the object exists, generate a pre-signed URL 52 | const command = new GetObjectCommand({ 53 | Bucket: "carpoolnubucket", 54 | Key: key, 55 | }); 56 | const url = await getSignedUrl(s3Client, command, { expiresIn: expiry }); 57 | return url; 58 | } catch (error) { 59 | console.error("Error getting image url", error); 60 | return null; 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/components/MapLegend.tsx: -------------------------------------------------------------------------------- 1 | import RedSquare from "../../public/driver-dest.png"; 2 | import BlueSquare from "../../public/user-dest.png"; 3 | import UserDriver from "../../public/user-dest-driver.png"; 4 | import OrangeSquare from "../../public/rider-dest.png"; 5 | import Image from "next/image"; 6 | interface MapLegendProps { 7 | role: string; 8 | } 9 | export const MapLegend = (props: MapLegendProps) => { 10 | const role = props.role; 11 | return ( 12 | <> 13 |
14 |
15 | {(role === "VIEWER" || role === "RIDER") && ( 16 | 23 | )} 24 | {role === "DRIVER" && ( 25 | 32 | )} 33 |

My Destination

34 |
35 | {(role === "VIEWER" || role === "RIDER") && ( 36 |
37 | 38 |

{"Driver Destination"}

39 |
40 | )} 41 | {(role === "VIEWER" || role === "DRIVER") && ( 42 |
43 | 50 |

{"Rider Destination"}

51 |
52 | )} 53 |
54 | 55 | ); 56 | }; 57 | -------------------------------------------------------------------------------- /src/components/Map/VisibilityToggle.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect, CSSProperties } from "react"; 2 | import mapboxgl from "mapbox-gl"; 3 | interface VisibilityToggleProps { 4 | map: mapboxgl.Map | undefined; 5 | className?: string; 6 | style?: CSSProperties; 7 | } 8 | 9 | const VisibilityToggle = ({ map, style }: VisibilityToggleProps) => { 10 | const [visibility, setVisibility] = useState("ALL"); 11 | 12 | useEffect(() => { 13 | if (!map) return; 14 | 15 | const updateVisibility = () => { 16 | const layerIds = ["riders", "drivers"]; 17 | 18 | layerIds.forEach((layer) => { 19 | if (map.getLayer(layer)) { 20 | if (visibility === "ALL") { 21 | map.setLayoutProperty(layer, "visibility", "visible"); 22 | } else if (visibility === "RIDERS" && layer === "riders") { 23 | map.setLayoutProperty("riders", "visibility", "visible"); 24 | map.setLayoutProperty("drivers", "visibility", "none"); 25 | } else if (visibility === "DRIVERS" && layer === "drivers") { 26 | map.setLayoutProperty("riders", "visibility", "none"); 27 | map.setLayoutProperty("drivers", "visibility", "visible"); 28 | } 29 | } else { 30 | console.warn(`Layer ${layer} does not exist in the map's style.`); 31 | } 32 | }); 33 | }; 34 | 35 | // check style loaded 36 | if (map.isStyleLoaded()) { 37 | updateVisibility(); 38 | } else { 39 | map.on("style.load", updateVisibility); 40 | return () => { 41 | map.off("style.load", updateVisibility); 42 | }; 43 | } 44 | }, [visibility, map]); 45 | 46 | return ( 47 | 56 | ); 57 | }; 58 | 59 | export default VisibilityToggle; 60 | -------------------------------------------------------------------------------- /src/components/Messages/SendBar.tsx: -------------------------------------------------------------------------------- 1 | import React, { useRef, useState } from "react"; 2 | import Image from "next/image"; 3 | import sendIcon from "../../../public/sendIcon.png"; 4 | 5 | interface SendBarProps { 6 | onSendMessage: (content: string) => void; 7 | } 8 | 9 | const SendBar = ({ onSendMessage }: SendBarProps) => { 10 | const [messageContent, setMessageContent] = useState(""); 11 | const messageInputRef = useRef(null); 12 | 13 | const handleSend = () => { 14 | if (messageContent.trim()) { 15 | onSendMessage(messageContent.trim()); 16 | setMessageContent(""); 17 | if (messageInputRef.current) { 18 | messageInputRef.current.textContent = ""; 19 | } 20 | } 21 | }; 22 | 23 | const handleKeyPress = (e: React.KeyboardEvent) => { 24 | if (e.key === "Enter" && !e.shiftKey) { 25 | e.preventDefault(); 26 | handleSend(); 27 | } 28 | }; 29 | 30 | return ( 31 |
32 |
33 |
setMessageContent(e.currentTarget.textContent || "")} 47 | onKeyDown={handleKeyPress} 48 | >
49 |
50 | 53 |
54 |
55 | ); 56 | }; 57 | 58 | export default SendBar; 59 | -------------------------------------------------------------------------------- /src/components/TextField.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { FieldError } from "react-hook-form"; 3 | import { ErrorDisplay } from "../styles/profile"; 4 | import { classNames } from "../utils/classNames"; 5 | import { ReactNode, useRef } from "react"; 6 | 7 | type TextFieldOwnProps = { 8 | label?: string; 9 | error?: FieldError; 10 | charLimit?: number; 11 | inputClassName?: string; 12 | className?: string; 13 | isDisabled?: boolean; 14 | }; 15 | 16 | type TextFieldProps = TextFieldOwnProps & 17 | React.ComponentPropsWithoutRef<"input">; 18 | const customSuffixIcon = (): ReactNode => { 19 | return
; 20 | }; 21 | export const TextField = React.forwardRef( 22 | ( 23 | { 24 | charLimit = 524288, 25 | isDisabled, 26 | label, 27 | id, 28 | name, 29 | error, 30 | type, 31 | className, 32 | inputClassName, 33 | ...rest 34 | }, 35 | forwardedRef 36 | ) => ( 37 |
38 |
39 | 54 | {type === "month" && ( 55 |
56 | {customSuffixIcon()} 57 |
58 | )} 59 |
60 | {error && {error.message}} 61 |
62 | ) 63 | ); 64 | 65 | TextField.displayName = "TextField"; 66 | -------------------------------------------------------------------------------- /src/pages/admin.tsx: -------------------------------------------------------------------------------- 1 | import { GetServerSidePropsContext, NextPage } from "next"; 2 | import { getSession } from "next-auth/react"; 3 | import Header from "../components/Header"; 4 | import AdminSidebar from "../components/Admin/AdminSidebar"; 5 | import { useState } from "react"; 6 | import UserManagement from "../components/Admin/UserManagement"; 7 | import Spinner from "../components/Spinner"; 8 | import { Permission } from "@prisma/client"; 9 | import AdminData from "../components/Admin/AdminData"; 10 | 11 | export async function getServerSideProps(context: GetServerSidePropsContext) { 12 | const session = await getSession(context); 13 | 14 | if (session?.user) { 15 | if (session.user.permission === "USER") { 16 | return { 17 | redirect: { 18 | destination: "/", 19 | permanent: false, 20 | }, 21 | }; 22 | } 23 | } else { 24 | return { 25 | redirect: { 26 | destination: "/", 27 | permanent: false, 28 | }, 29 | }; 30 | } 31 | 32 | return { 33 | props: { 34 | userPermission: session.user.permission, 35 | }, 36 | }; 37 | } 38 | 39 | interface AdminProps { 40 | userPermission: Permission; 41 | } 42 | 43 | const Admin: NextPage = ({ userPermission }) => { 44 | const [option, setOption] = useState("management"); 45 | return ( 46 |
47 |
48 | {!userPermission ? ( 49 | 50 | ) : ( 51 |
52 |
53 | 54 |
55 |
56 | {option === "management" ? ( 57 | 58 | ) : ( 59 | 60 | )} 61 |
62 |
63 | )} 64 |
65 | ); 66 | }; 67 | export default Admin; 68 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | /** 3 | * TODO: add theme to follow the branding rules of Northeastern 4 | * https://brand.northeastern.edu/visual-design/typography/ 5 | */ 6 | module.exports = { 7 | content: [ 8 | "./src/pages/**/*.{js,ts,jsx,tsx}", 9 | "./src/components/**/*.{js,ts,jsx,tsx}", 10 | ], 11 | theme: { 12 | extend: { 13 | colors: { 14 | "northeastern-red": "#C8102E", 15 | "light-red": "#FFE6E6", 16 | "busy-red": "#FFA9A9", 17 | "okay-yellow": "#FFCB11", 18 | "good-green": "#C7EFB3", 19 | }, 20 | keyframes: { 21 | gradientShift: { 22 | "0%": { 23 | backgroundSize: "100% 100%, 120% 120%", 24 | }, 25 | "25%": { 26 | backgroundSize: "110%% 110%%, 110%% 110%", 27 | }, 28 | "50%": { 29 | backgroundSize: "120% 120%, 100% 100%", 30 | }, 31 | "75%": { 32 | backgroundSize: "110%% 110%, 110% 110%", 33 | }, 34 | "100%": { 35 | backgroundSize: "100% 100%, 120% 120%", 36 | }, 37 | }, 38 | }, 39 | animation: { 40 | "gradient-shift-15s": "gradientShift 15s ease-in-out infinite", 41 | }, 42 | fontFamily: { 43 | montserrat: ["Montserrat", "sans-serif"], 44 | lato: ["Lato", "sans-serif"], 45 | }, 46 | backgroundImage: { 47 | floaty: 48 | "radial-gradient(ellipse 100% 80% at -10% 110% , #C8102E, #FFA9A9, transparent)," + 49 | "radial-gradient(ellipse 70% 100% at 110% -10% , #C8102E, #FFA9A9, white )", 50 | }, 51 | }, 52 | screens: { 53 | sm: "576px", 54 | // => @media (min-width: 576px) { ... } 55 | 56 | // ipad 14 size 57 | md: "834px", 58 | // => @media (min-width: 834px) { ... } 59 | 60 | lg: "1440px", 61 | // => @media (min-width: 1440px) { ... } 62 | }, 63 | }, 64 | plugins: [ 65 | require("@tailwindcss/forms"), 66 | require("tailwind-scrollbar")({ 67 | nocompatible: true, 68 | preferredStrategy: "pseudoelements", 69 | }), 70 | ], 71 | }; 72 | -------------------------------------------------------------------------------- /src/components/Sidebar/RequestSidebar.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from "react"; 2 | import { EnhancedPublicUser, PublicUser, User } from "../../utils/types"; 3 | import { SidebarContent } from "./SidebarContent"; 4 | import CustomSelect from "./CustomSelect"; 5 | interface RequestSidebarProps { 6 | received: EnhancedPublicUser[]; 7 | sent: EnhancedPublicUser[]; 8 | viewRoute: (user: User, otherUser: PublicUser) => void; 9 | disabled: boolean; 10 | onUserSelect: (userId: string) => void; 11 | selectedUser: EnhancedPublicUser | null; 12 | } 13 | interface Option { 14 | value: T; 15 | label: string; 16 | } 17 | const RequestSidebar = (props: RequestSidebarProps) => { 18 | const [curOption, setCurOption] = useState<"received" | "sent" | "all">( 19 | "all" 20 | ); 21 | const options: Option<"received" | "sent" | "all">[] = [ 22 | { value: "all", label: "All" }, 23 | { value: "received", label: "Received" }, 24 | { value: "sent", label: "Sent" }, 25 | ]; 26 | const handleCardClick = (userId: string) => { 27 | props.onUserSelect(userId); 28 | }; 29 | return ( 30 |
31 |
32 |
33 |
Requests
34 | 40 |
41 |
42 | 57 |
58 | ); 59 | }; 60 | 61 | export default RequestSidebar; 62 | -------------------------------------------------------------------------------- /src/components/Sidebar/Sidebar.tsx: -------------------------------------------------------------------------------- 1 | import ExploreSidebar from "./ExploreSidebar"; 2 | import RequestSidebar from "./RequestSidebar"; 3 | import { 4 | EnhancedPublicUser, 5 | FiltersState, 6 | PublicUser, 7 | User, 8 | } from "../../utils/types"; 9 | import mapboxgl from "mapbox-gl"; 10 | import { viewRoute } from "../../utils/map/viewRoute"; 11 | import { HeaderOptions } from "../Header"; 12 | import { trpc } from "../../utils/trpc"; 13 | import _ from "lodash"; 14 | import { Request } from "@prisma/client"; 15 | import React from "react"; 16 | 17 | interface SidebarProps { 18 | sidebarType: HeaderOptions; 19 | setFilters: React.Dispatch>; 20 | setSort: React.Dispatch>; 21 | sort: string; 22 | filters: FiltersState; 23 | defaultFilters: FiltersState; 24 | map: mapboxgl.Map; 25 | role: string; 26 | recs: EnhancedPublicUser[]; 27 | favs: EnhancedPublicUser[]; 28 | received: EnhancedPublicUser[]; 29 | sent: EnhancedPublicUser[]; 30 | onViewRouteClick: (user: User, otherUser: PublicUser) => void; 31 | onUserSelect: (userId: string) => void; 32 | selectedUser: EnhancedPublicUser | null; 33 | } 34 | 35 | export const SidebarPage = (props: SidebarProps) => { 36 | let disabled = false; 37 | if (props.role === "VIEWER") { 38 | disabled = true; 39 | } 40 | if (props.sidebarType === "explore") { 41 | return ( 42 | 54 | ); 55 | } else { 56 | return ( 57 | 65 | ); 66 | } 67 | }; 68 | -------------------------------------------------------------------------------- /src/utils/map/addMapEvents.tsx: -------------------------------------------------------------------------------- 1 | import mapboxgl, { 2 | GeoJSONSource, 3 | Map, 4 | MapboxGeoJSONFeature, 5 | MapLayerMouseEvent, 6 | NavigationControl, 7 | } from "mapbox-gl"; 8 | import { PublicUser } from "../types"; 9 | import { User } from "../types"; 10 | import { Dispatch, SetStateAction } from "react"; 11 | import { GeoJSON } from "geojson"; 12 | 13 | const addMapEvents = ( 14 | map: Map, 15 | user: User, 16 | setPopupUser: Dispatch> 17 | ) => { 18 | map.addControl(new NavigationControl(), "bottom-right"); 19 | 20 | map.on("click", "clusters", (e) => { 21 | const features = map.queryRenderedFeatures(e.point, { 22 | layers: ["clusters"], 23 | }); 24 | const clusterId = features[0]!.properties!.cluster_id; 25 | const source = map.getSource("company-locations") as GeoJSONSource; 26 | source.getClusterExpansionZoom(clusterId, (err, zoom) => { 27 | if (err) return; 28 | if (features[0]!.geometry.type === "Point") { 29 | map.easeTo({ 30 | center: [ 31 | features[0]!.geometry.coordinates[0]!, 32 | features[0]!.geometry.coordinates[1]!, 33 | ], 34 | zoom: zoom, 35 | }); 36 | } 37 | }); 38 | }); 39 | function handlePointClick(e: MapLayerMouseEvent) { 40 | if (!e.features) return; 41 | const layers = ["riders", "drivers"]; 42 | const pointFeatures = map.queryRenderedFeatures(e.point, { layers }); 43 | 44 | if (pointFeatures.length === 0) return; 45 | 46 | const users = pointFeatures.map( 47 | (feature) => feature.properties as PublicUser 48 | ); 49 | 50 | setPopupUser(users); 51 | } 52 | 53 | map.on("click", "riders", handlePointClick); 54 | map.on("click", "drivers", handlePointClick); 55 | 56 | map.on("mouseenter", "clusters", () => { 57 | map.getCanvas().style.cursor = "pointer"; 58 | }); 59 | map.on("mouseleave", "clusters", () => { 60 | map.getCanvas().style.cursor = ""; 61 | }); 62 | 63 | document.getElementById("fly")!.addEventListener("click", () => { 64 | map.flyTo({ 65 | center: [user.companyCoordLng, user.companyCoordLat], 66 | essential: true, 67 | }); 68 | }); 69 | }; 70 | 71 | export default addMapEvents; 72 | -------------------------------------------------------------------------------- /src/utils/profile/updateUser.ts: -------------------------------------------------------------------------------- 1 | import { toast } from "react-toastify"; 2 | import { NextRouter } from "next/router"; 3 | import { trpc } from "../trpc"; 4 | import { UserInfo } from "../types"; 5 | export const updateUser = async ({ 6 | userInfo, 7 | sessionName, 8 | mutation, 9 | }: { 10 | userInfo: UserInfo; 11 | sessionName: string; 12 | mutation: ReturnType; 13 | }) => { 14 | const daysWorkingParsed: string = userInfo.daysWorking 15 | .map((val: boolean) => { 16 | if (val) { 17 | return "1"; 18 | } else { 19 | return "0"; 20 | } 21 | }) 22 | .join(","); 23 | await mutation.mutateAsync({ 24 | role: userInfo.role, 25 | status: userInfo.status, 26 | seatAvail: userInfo.seatAvail, 27 | companyName: userInfo.companyName, 28 | companyAddress: userInfo.companyAddress, 29 | companyCoordLng: userInfo.companyCoordLng, 30 | companyCoordLat: userInfo.companyCoordLat, 31 | startAddress: userInfo.startAddress, 32 | startCoordLng: userInfo.startCoordLng, 33 | startCoordLat: userInfo.startCoordLat, 34 | isOnboarded: true, 35 | preferredName: userInfo.preferredName || sessionName, 36 | pronouns: userInfo.pronouns, 37 | daysWorking: daysWorkingParsed, 38 | startTime: userInfo.startTime?.toISOString(), 39 | endTime: userInfo.endTime?.toISOString(), 40 | bio: userInfo.bio, 41 | coopStartDate: userInfo.coopStartDate!, 42 | coopEndDate: userInfo.coopEndDate!, 43 | licenseSigned: true, 44 | }); 45 | }; 46 | export const useEditUserMutation = ( 47 | router: NextRouter, 48 | onComplete: () => void, 49 | pushMap: boolean = true 50 | ) => { 51 | const utils = trpc.useContext(); 52 | 53 | return trpc.user.edit.useMutation({ 54 | onSuccess: async () => { 55 | await utils.user.me.refetch(); 56 | await utils.user.recommendations.me.invalidate(); 57 | await utils.mapbox.geoJsonUserList.invalidate(); 58 | if (pushMap) { 59 | router.push("/").then(() => { 60 | onComplete(); 61 | }); 62 | } else { 63 | onComplete(); 64 | } 65 | }, 66 | onError: (error) => { 67 | toast.error(`Something went wrong: ${error.message}`); 68 | onComplete(); 69 | }, 70 | }); 71 | }; 72 | -------------------------------------------------------------------------------- /src/server/router/user/admin.ts: -------------------------------------------------------------------------------- 1 | import { adminRouter, router } from "../createRouter"; 2 | import { z } from "zod"; 3 | import { Permission, Status } from "@prisma/client"; 4 | import { Role } from "@prisma/client"; 5 | // Router for admin dashboard queries, only Managers can edit roles 6 | // User must be Manager or Admin to view user data 7 | export const adminDataRouter = router({ 8 | getAllUsers: adminRouter.query(async ({ ctx }) => { 9 | return ctx.prisma.user.findMany({ 10 | where: { 11 | email: { 12 | not: null, 13 | }, 14 | }, 15 | select: { 16 | id: true, 17 | email: true, 18 | permission: true, 19 | isOnboarded: true, 20 | dateCreated: true, 21 | role: true, 22 | status: true, 23 | }, 24 | }); 25 | }), 26 | getCarpoolGroups: adminRouter.query(async ({ ctx }) => { 27 | return ctx.prisma.carpoolGroup.findMany({ 28 | where: { 29 | AND: [ 30 | { 31 | users: { 32 | some: { 33 | role: Role.DRIVER, 34 | status: Status.ACTIVE, 35 | }, 36 | }, 37 | }, 38 | { 39 | users: { 40 | some: { 41 | role: Role.RIDER, 42 | status: Status.ACTIVE, 43 | }, 44 | }, 45 | }, 46 | ], 47 | }, 48 | select: { 49 | id: true, 50 | dateCreated: true, 51 | _count: { 52 | select: { 53 | users: true, 54 | }, 55 | }, 56 | }, 57 | }); 58 | }), 59 | updateUserPermission: adminRouter 60 | .input( 61 | z.object({ 62 | userId: z.string(), 63 | permission: z.nativeEnum(Permission), 64 | }) 65 | ) 66 | .mutation(async ({ ctx, input }) => { 67 | const permission = ctx.session.user?.permission; 68 | if (permission !== "MANAGER") { 69 | throw new Error("Unauthorized access."); 70 | } 71 | if (input.userId === ctx.session.user?.id) { 72 | throw new Error("Cannot change own permission."); 73 | } 74 | 75 | return ctx.prisma.user.update({ 76 | where: { 77 | id: input.userId, 78 | }, 79 | data: { 80 | permission: input.permission, 81 | }, 82 | }); 83 | }), 84 | }); 85 | -------------------------------------------------------------------------------- /src/pages/api/auth/[...nextauth].ts: -------------------------------------------------------------------------------- 1 | import NextAuth from "next-auth"; 2 | import { PrismaAdapter } from "@next-auth/prisma-adapter"; 3 | import { NextAuthOptions } from "next-auth"; 4 | import { prisma } from "../../../server/db/client"; 5 | import { serverEnv } from "../../../utils/env/server"; 6 | import AzureADProvider from "next-auth/providers/azure-ad"; 7 | import GoogleProvider from "next-auth/providers/google"; 8 | import { Adapter } from "next-auth/adapters"; 9 | import { Prisma } from "@prisma/client"; 10 | 11 | const CustomPrismaAdapter = (p: typeof prisma): Adapter => { 12 | return { 13 | ...PrismaAdapter(p), 14 | createUser: async (data: Prisma.UserCreateInput) => { 15 | const user = await p.user.create({ 16 | data: { 17 | ...data, 18 | image: null, 19 | }, 20 | }); 21 | return { 22 | ...user, 23 | email: user.email || "", 24 | }; 25 | }, 26 | }; 27 | }; 28 | 29 | export const authOptions: NextAuthOptions = { 30 | callbacks: { 31 | session({ session, user }) { 32 | if (session.user) { 33 | session.user.id = user.id; 34 | session.user.isOnboarded = user.isOnboarded; 35 | session.user.permission = user.permission; 36 | } 37 | return session; 38 | }, 39 | }, 40 | secret: serverEnv.NEXTAUTH_SECRET, 41 | logger: { 42 | error(code, metadata) { 43 | console.error(code, metadata); 44 | }, 45 | warn(code) { 46 | console.warn(code); 47 | }, 48 | debug(code, metadata) { 49 | console.debug(code, metadata); 50 | }, 51 | }, 52 | adapter: CustomPrismaAdapter(prisma), 53 | 54 | providers: 55 | process.env.NEXT_PUBLIC_ENV === "staging" 56 | ? [ 57 | GoogleProvider({ 58 | clientId: serverEnv.GOOGLE_CLIENT_ID, 59 | clientSecret: serverEnv.GOOGLE_CLIENT_SECRET, 60 | }), 61 | AzureADProvider({ 62 | clientId: serverEnv.AZURE_CLIENT_ID, 63 | clientSecret: serverEnv.AZURE_CLIENT_SECRET, 64 | tenantId: serverEnv.AZURE_TENANT_ID, 65 | }), 66 | ] 67 | : [ 68 | AzureADProvider({ 69 | clientId: serverEnv.AZURE_CLIENT_ID, 70 | clientSecret: serverEnv.AZURE_CLIENT_SECRET, 71 | tenantId: serverEnv.AZURE_TENANT_ID, 72 | }), 73 | ], 74 | }; 75 | 76 | export default NextAuth(authOptions); 77 | -------------------------------------------------------------------------------- /prisma/migrations/20221009042524_base_schema/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateTable 2 | CREATE TABLE `account` ( 3 | `id` VARCHAR(191) NOT NULL, 4 | `user_id` VARCHAR(191) NOT NULL, 5 | `type` VARCHAR(191) NOT NULL, 6 | `provider` VARCHAR(191) NOT NULL, 7 | `provider_account_id` VARCHAR(191) NOT NULL, 8 | `refresh_token` TEXT NULL, 9 | `access_token` TEXT NULL, 10 | `expires_at` INTEGER NULL, 11 | `token_type` VARCHAR(191) NULL, 12 | `scope` VARCHAR(191) NULL, 13 | `id_token` TEXT NULL, 14 | `session_state` VARCHAR(191) NULL, 15 | 16 | UNIQUE INDEX `account_provider_provider_account_id_key`(`provider`, `provider_account_id`), 17 | PRIMARY KEY (`id`) 18 | ) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; 19 | 20 | -- CreateTable 21 | CREATE TABLE `session` ( 22 | `id` VARCHAR(191) NOT NULL, 23 | `session_token` VARCHAR(191) NOT NULL, 24 | `userId` VARCHAR(191) NOT NULL, 25 | `expires` DATETIME(3) NOT NULL, 26 | 27 | UNIQUE INDEX `session_session_token_key`(`session_token`), 28 | PRIMARY KEY (`id`) 29 | ) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; 30 | 31 | -- CreateTable 32 | CREATE TABLE `user` ( 33 | `id` VARCHAR(191) NOT NULL, 34 | `name` VARCHAR(191) NULL, 35 | `email` VARCHAR(191) NULL, 36 | `email_verified` DATETIME(3) NULL, 37 | `image` VARCHAR(191) NULL, 38 | `role` ENUM('RIDER', 'DRIVER') NOT NULL DEFAULT 'RIDER', 39 | `status` ENUM('ACTIVE', 'INACTIVE') NOT NULL DEFAULT 'ACTIVE', 40 | `seat_avail` INTEGER NOT NULL DEFAULT 0, 41 | `company_name` VARCHAR(191) NOT NULL DEFAULT '', 42 | `company_address` VARCHAR(191) NOT NULL DEFAULT '', 43 | `company_coord_lng` DOUBLE NOT NULL DEFAULT 0, 44 | `company_coord_lat` DOUBLE NOT NULL DEFAULT 0, 45 | `start_location` VARCHAR(191) NOT NULL DEFAULT '', 46 | `is_onboarded` BOOLEAN NOT NULL DEFAULT false, 47 | 48 | UNIQUE INDEX `user_email_key`(`email`), 49 | PRIMARY KEY (`id`) 50 | ) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; 51 | 52 | -- CreateTable 53 | CREATE TABLE `verification_token` ( 54 | `identifier` VARCHAR(191) NOT NULL, 55 | `token` VARCHAR(191) NOT NULL, 56 | `expires` DATETIME(3) NOT NULL, 57 | 58 | UNIQUE INDEX `verification_token_token_key`(`token`), 59 | UNIQUE INDEX `verification_token_identifier_token_key`(`identifier`, `token`) 60 | ) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; 61 | -------------------------------------------------------------------------------- /src/utils/email.ts: -------------------------------------------------------------------------------- 1 | import { SendTemplatedEmailCommandInput } from "@aws-sdk/client-ses"; 2 | 3 | export interface BaseEmailSchema { 4 | senderName: string; 5 | senderEmail: string; 6 | receiverName: string; 7 | receiverEmail: string; 8 | } 9 | 10 | export interface RequestEmailSchema extends BaseEmailSchema { 11 | messagePreview: string; 12 | isDriver: boolean; 13 | } 14 | 15 | export interface MessageEmailSchema extends BaseEmailSchema { 16 | messageText: string; 17 | } 18 | 19 | export interface AcceptanceEmailSchema extends BaseEmailSchema { 20 | isDriver: boolean; 21 | } 22 | 23 | export function generateEmailParams( 24 | schema: RequestEmailSchema | MessageEmailSchema | AcceptanceEmailSchema, 25 | type: 'request' | 'message' | 'acceptance', 26 | includeCc: boolean 27 | ): SendTemplatedEmailCommandInput { 28 | let templateName: string; 29 | let templateData: Record; 30 | 31 | switch (type) { 32 | case 'request': 33 | const requestSchema = schema as RequestEmailSchema; 34 | templateName = requestSchema.isDriver ? 'DriverRequestTemplate' : 'RiderRequestTemplate'; 35 | templateData = { 36 | preferredName: requestSchema.receiverName, 37 | OtherUser: requestSchema.senderName, 38 | message: requestSchema.messagePreview, 39 | }; 40 | break; 41 | case 'message': 42 | const messageSchema = schema as MessageEmailSchema; 43 | templateName = 'MessageNotificationTemplate'; 44 | templateData = { 45 | preferredName: messageSchema.receiverName, 46 | OtherUser: messageSchema.senderName, 47 | message: messageSchema.messageText, 48 | }; 49 | break; 50 | case 'acceptance': 51 | const acceptanceSchema = schema as AcceptanceEmailSchema; 52 | templateName = acceptanceSchema.isDriver ? 'DriverAcceptanceTemplate' : 'RiderAcceptanceTemplate'; 53 | templateData = { 54 | preferredName: acceptanceSchema.receiverName, 55 | OtherUser: acceptanceSchema.senderName, 56 | }; 57 | break; 58 | default: 59 | throw new Error('Invalid email type'); 60 | } 61 | 62 | const destination: { ToAddresses: string[], CcAddresses?: string[] } = { 63 | ToAddresses: [schema.receiverEmail], 64 | }; 65 | 66 | if (includeCc) { 67 | destination.CcAddresses = [schema.senderEmail]; 68 | } 69 | 70 | return { 71 | Source: "no-reply@carpoolnu.com", 72 | Destination: destination, 73 | Template: templateName, 74 | TemplateData: JSON.stringify(templateData), 75 | }; 76 | } 77 | -------------------------------------------------------------------------------- /src/components/MapConnectPortal.tsx: -------------------------------------------------------------------------------- 1 | import { EnhancedPublicUser, PublicUser } from "../utils/types"; 2 | import { User } from "@prisma/client"; 3 | import { ConnectCard } from "./UserCards/ConnectCard"; 4 | import { Dialog } from "@headlessui/react"; 5 | import { useRef } from "react"; 6 | 7 | interface ConnectPortalProps { 8 | otherUsers: PublicUser[] | null; 9 | extendUser: (user: PublicUser) => EnhancedPublicUser; 10 | onViewRouteClick: (user: User, otherUser: PublicUser) => void; 11 | onViewRequest: (userId: string) => void; 12 | onClose: () => void; 13 | } 14 | 15 | export const MapConnectPortal = (props: ConnectPortalProps) => { 16 | return ( 17 | 0)} 19 | onClose={props.onClose} 20 | className="relative z-50" 21 | > 22 |
23 |
24 | 25 |
26 |
40 | {props.otherUsers && 41 | props.otherUsers.map((user: PublicUser) => ( 42 |
43 | { 48 | if (action === "connect") props.onClose(); 49 | }} 50 | /> 51 |
52 | ))} 53 |
54 |
55 |
56 |
57 |
58 |
59 | ); 60 | }; 61 | -------------------------------------------------------------------------------- /src/utils/publicUser.ts: -------------------------------------------------------------------------------- 1 | import { User } from "@prisma/client"; 2 | import { TRPCError } from "@trpc/server"; 3 | import { serverEnv } from "./env/server"; 4 | import { PublicUser, PoiData } from "./types"; 5 | 6 | /** 7 | * Converts the given ``User`` to a ``PublicUser``, as to hide sensitive data. 8 | * 9 | * @param user a rider or driver. 10 | * @returns non-sensitive information about a user. 11 | */ 12 | export const convertToPublic = (user: User): PublicUser => { 13 | return { 14 | id: user.id, 15 | name: user.name, 16 | email: user.email, 17 | image: user.image, 18 | bio: user.bio, 19 | preferredName: user.preferredName, 20 | pronouns: user.pronouns, 21 | role: user.role, 22 | status: user.status, 23 | seatAvail: user.seatAvail, 24 | companyName: user.companyName, 25 | daysWorking: user.daysWorking, 26 | startTime: user.startTime, 27 | endTime: user.endTime, 28 | coopEndDate: user.coopEndDate, 29 | coopStartDate: user.coopStartDate, 30 | startPOILocation: user.startPOILocation, 31 | startPOICoordLng: user.startPOICoordLng, 32 | startPOICoordLat: user.startPOICoordLat, 33 | companyAddress: user.companyAddress, 34 | companyCoordLng: user.companyCoordLng, 35 | companyCoordLat: user.companyCoordLat, 36 | carpoolId: user.carpoolId, 37 | }; 38 | }; 39 | 40 | export const roundCoord = (coord: number) => { 41 | return Math.round((coord + Number.EPSILON) * 100000) / 100000; 42 | }; 43 | 44 | /** 45 | * Generates place of interest data given a point on the map. 46 | * 47 | * @param longitude the geographical longitude. 48 | * @param latitude the geographical latitude. 49 | * @returns non-specific location information (AKA POI). 50 | */ 51 | export const generatePoiData = async ( 52 | longitude: number, 53 | latitude: number 54 | ): Promise => { 55 | const endpoint = [ 56 | "https://api.mapbox.com/geocoding/v5/mapbox.places/", 57 | longitude, 58 | ", ", 59 | latitude, 60 | ".json?types=poi&access_token=", 61 | serverEnv.NEXT_PUBLIC_MAPBOX_ACCESS_TOKEN, 62 | ].join(""); 63 | const data = await fetch(endpoint) 64 | .then((response) => response.json()) 65 | .catch((err) => { 66 | throw new TRPCError({ 67 | code: "INTERNAL_SERVER_ERROR", 68 | message: "Unexpected error. Please try again.", 69 | cause: err, 70 | }); 71 | }); 72 | 73 | return { 74 | location: data.features[0]?.text || "NOT FOUND", 75 | coordLng: data.features[0]?.center[0] ?? -999, 76 | coordLat: data.features[0]?.center[1] ?? -999, 77 | }; 78 | }; 79 | -------------------------------------------------------------------------------- /src/components/Map/AddressCombobox.tsx: -------------------------------------------------------------------------------- 1 | import { Combobox, Transition } from "@headlessui/react"; 2 | import { Fragment } from "react"; 3 | import { CarpoolFeature, CarpoolAddress } from "../../utils/types"; 4 | 5 | interface AddressComboboxProps { 6 | name: "startAddress" | "companyAddress"; 7 | className: string; 8 | addressSelected: CarpoolAddress; 9 | addressSetter: (val: CarpoolAddress) => void; 10 | addressUpdater: (val: string) => void; 11 | addressSuggestions: CarpoolFeature[]; 12 | error?: string; 13 | placeholder: string; 14 | } 15 | 16 | const AddressCombobox = ({ 17 | className, 18 | addressSelected, 19 | addressSetter, 20 | addressUpdater, 21 | addressSuggestions, 22 | placeholder, 23 | error, 24 | }: AddressComboboxProps) => { 25 | return ( 26 |
27 | { 30 | addressSetter(val); 31 | }} 32 | as="div" 33 | > 34 | feat.place_name} 39 | onChange={(e) => addressUpdater(e.target.value)} 40 | placeholder={placeholder} 41 | /> 42 | 48 | 49 | {addressSuggestions.length === 0 ? ( 50 |
51 | Nothing found. 52 |
53 | ) : ( 54 | addressSuggestions.map((feat) => ( 55 | 58 | `cursor-default select-none border-black p-3 ${ 59 | active ? "bg-blue-400 text-white" : "text-gray-900" 60 | }` 61 | } 62 | value={feat} 63 | > 64 | {feat.place_name} 65 | 66 | )) 67 | )} 68 |
69 |
70 |
71 |
72 | ); 73 | }; 74 | 75 | export default AddressCombobox; 76 | -------------------------------------------------------------------------------- /src/pages/sign-in.tsx: -------------------------------------------------------------------------------- 1 | import { GetServerSidePropsContext, NextPage } from "next"; 2 | import { getSession, signIn } from "next-auth/react"; 3 | import React from "react"; 4 | import Head from "next/head"; 5 | import Header from "../components/Header"; 6 | import { trackEvent } from "../utils/mixpanel"; 7 | 8 | export async function getServerSideProps(context: GetServerSidePropsContext) { 9 | const session = await getSession(context); 10 | 11 | if (session?.user) { 12 | if (session.user.isOnboarded) { 13 | return { 14 | redirect: { 15 | destination: "/", 16 | permanent: false, 17 | }, 18 | }; 19 | } 20 | return { 21 | redirect: { 22 | destination: "/profile/setup", 23 | permanent: false, 24 | }, 25 | }; 26 | } 27 | 28 | return { 29 | props: {}, 30 | }; 31 | } 32 | 33 | const SignIn: NextPage = () => { 34 | const handleOnNortheasternSignInClick = ( 35 | e: React.MouseEvent 36 | ) => { 37 | e.preventDefault(); 38 | trackEvent("Sign In Attempt", { provider: "Northeastern" }); 39 | signIn("azure-ad", { 40 | callbackUrl: "/", 41 | }); 42 | }; 43 | 44 | const handleOnGoogleSignInClick = ( 45 | e: React.MouseEvent 46 | ) => { 47 | e.preventDefault(); 48 | trackEvent("Sign In Attempt", { provider: "Google" }); 49 | signIn("google", { 50 | callbackUrl: "/", 51 | }); 52 | }; 53 | 54 | return ( 55 | <> 56 | 57 | Sign In - NU Carpool 58 | 59 | 60 |
61 |
62 |
63 | 68 | {process.env.NEXT_PUBLIC_ENV === "staging" && ( 69 | 74 | )} 75 |
76 |
77 | 78 | ); 79 | }; 80 | 81 | export default SignIn; 82 | -------------------------------------------------------------------------------- /src/utils/map/updateCompanyLocation.ts: -------------------------------------------------------------------------------- 1 | import mapboxgl from "mapbox-gl"; 2 | import BlueEnd from "../../../public/user-dest.png"; 3 | import BlueDriverEnd from "../../../public/user-dest-driver.png"; 4 | import RedDriverEnd from "../../../public/driver-dest.png"; 5 | import OrangeRiderEnd from "../../../public/rider-dest.png"; 6 | import { Role } from "@prisma/client"; 7 | import { GeoJSON } from "geojson"; 8 | 9 | const updateCompanyLocation = ( 10 | map: mapboxgl.Map, 11 | companyLongitude: number, 12 | companyLatitude: number, 13 | role: Role, 14 | userId: string, 15 | isCurrent: boolean = false, 16 | remove: boolean = false 17 | ): void => { 18 | let img, sourceId: string, layerId: string; 19 | 20 | if (isCurrent) { 21 | img = role === Role.DRIVER ? BlueDriverEnd.src : BlueEnd.src; 22 | sourceId = "current-user-company-source"; 23 | layerId = "current-user-company-layer"; 24 | } else { 25 | img = role === Role.DRIVER ? RedDriverEnd.src : OrangeRiderEnd.src; 26 | sourceId = `other-user-${userId}-company-source`; 27 | layerId = `other-user-${userId}-company-layer`; 28 | } 29 | if (remove) { 30 | if (map.getLayer(layerId)) { 31 | map.removeLayer(layerId); 32 | } 33 | if (map.getSource(sourceId)) { 34 | map.removeSource(sourceId); 35 | } 36 | if (map.hasImage(`${sourceId}-image`)) { 37 | map.removeImage(`${sourceId}-image`); 38 | } 39 | return; 40 | } 41 | map.loadImage(img, (error, image) => { 42 | if (error) throw error; 43 | 44 | const imageId = `${sourceId}-image`; 45 | if (!map.hasImage(imageId)) { 46 | if (image instanceof HTMLImageElement || image instanceof ImageBitmap) { 47 | map.addImage(imageId, image); 48 | } 49 | } 50 | const feature: GeoJSON.Feature = { 51 | type: "Feature", 52 | geometry: { 53 | type: "Point", 54 | coordinates: [companyLongitude, companyLatitude], 55 | }, 56 | properties: {}, 57 | }; 58 | 59 | // Create source if it doesn't exist 60 | let source = map.getSource(sourceId) as mapboxgl.GeoJSONSource | undefined; 61 | if (source) { 62 | // If source exists, update its data 63 | source.setData(feature); 64 | } else { 65 | // Create the source and layer if they don't exist 66 | map.addSource(sourceId, { 67 | type: "geojson", 68 | data: feature, 69 | }); 70 | 71 | map.addLayer({ 72 | id: layerId, 73 | type: "symbol", 74 | source: sourceId, 75 | layout: { 76 | "icon-image": imageId, 77 | "icon-allow-overlap": true, 78 | "icon-size": 0.33, 79 | }, 80 | }); 81 | } 82 | }); 83 | }; 84 | 85 | export default updateCompanyLocation; 86 | -------------------------------------------------------------------------------- /src/components/Sidebar/CustomSelect.tsx: -------------------------------------------------------------------------------- 1 | import { Listbox, Transition } from "@headlessui/react"; 2 | import { FaChevronDown } from "react-icons/fa"; 3 | import React, { Fragment } from "react"; 4 | import { FaCheck } from "react-icons/fa6"; 5 | 6 | interface Option { 7 | value: T; 8 | label: string; 9 | } 10 | 11 | interface CustomSelectProps { 12 | value: T; 13 | onChange: React.Dispatch>; 14 | options: Option[]; 15 | title?: string; 16 | className?: string; 17 | } 18 | 19 | const CustomSelect = ({ 20 | value, 21 | onChange, 22 | options, 23 | title, 24 | className, 25 | }: CustomSelectProps) => { 26 | return ( 27 |
28 | 29 |
30 | 31 | {title ? ( 32 | {title} 33 | ) : ( 34 | 35 | {options.find((opt) => value === opt.value)?.label} 36 | 37 | )} 38 | 39 | 41 | 42 | 48 | 49 | {options.map((option) => ( 50 | 53 | `relative cursor-default select-none py-2 pl-3 pr-8 ${ 54 | active ? "bg-northeastern-red text-white" : "text-black" 55 | }` 56 | } 57 | value={option.value} 58 | > 59 | {({ selected }) => ( 60 | 65 | {option.label} 66 | 67 | )} 68 | 69 | ))} 70 | 71 | 72 |
73 |
74 |
75 | ); 76 | }; 77 | 78 | export default CustomSelect; 79 | -------------------------------------------------------------------------------- /src/components/Radio.tsx: -------------------------------------------------------------------------------- 1 | import { Role } from "@prisma/client"; 2 | import React, { useEffect, useState } from "react"; 3 | import { FieldError } from "react-hook-form"; 4 | import styled from "styled-components"; 5 | 6 | type RadioOwnProps = { 7 | label?: string; 8 | error?: FieldError; 9 | role?: Role; 10 | value: Role; 11 | currentlySelected: Role; 12 | }; 13 | 14 | type RadioProps = RadioOwnProps & React.ComponentPropsWithoutRef<"input">; 15 | 16 | const StyledActiveRadioButton = styled.label` 17 | background-color: #c8102e; 18 | color: white; 19 | 20 | font-family: "Montserrat"; 21 | font-style: normal; 22 | font-weight: 500; 23 | font-size: 24px; 24 | border-radius: 10px; 25 | text-align: center; 26 | display: flex; 27 | align-items: center; 28 | justify-content: center; 29 | `; 30 | 31 | const StyledInactiveRadioButton = styled.label` 32 | background-color: white; 33 | color: black; 34 | border: 1px solid black; 35 | 36 | font-family: "Montserrat"; 37 | font-style: normal; 38 | font-weight: 500; 39 | font-size: 24px; 40 | border-radius: 10px; 41 | text-align: center; 42 | display: flex; 43 | align-items: center; 44 | justify-content: center; 45 | `; 46 | 47 | const Radio = React.forwardRef( 48 | ( 49 | { 50 | label, 51 | id, 52 | name, 53 | value, 54 | error, 55 | currentlySelected, 56 | className, 57 | role, 58 | ...rest 59 | }, 60 | forwardedRef 61 | ): React.ReactElement => { 62 | const [isActive, setIsActive] = useState(false); 63 | 64 | useEffect(() => { 65 | setIsActive(currentlySelected === value); 66 | }, [currentlySelected, value]); 67 | 68 | const input = ( 69 | 78 | ); 79 | if (isActive) { 80 | return ( 81 | 85 | {input} 86 | {label} 87 | {error && ( 88 |

{error.message}

89 | )} 90 |
91 | ); 92 | } else { 93 | return ( 94 | 98 | {input} 99 | {label} 100 | {error && ( 101 |

{error.message}

102 | )} 103 |
104 | ); 105 | } 106 | } 107 | ); 108 | 109 | Radio.displayName = "Radio"; 110 | 111 | export default Radio; 112 | -------------------------------------------------------------------------------- /src/server/router/README.md: -------------------------------------------------------------------------------- 1 | ## Routers 2 | 3 | --- 4 | 5 | This folder contains files relating to tRPC's [router](https://trpc.io/docs/v9/router). These routers serve as our API endpoints while allowing us to maintain type information for inputs and returns. The final router, contained in [`index.ts`](./index.ts), is referenced in [`[trpc].ts`](../../pages/api/trpc/%5Btrpc%5D.ts) when the tRPC endpoints are configured to work with Nextjs. For details on specific routers, more granular documentation is provided in the router files. 6 | 7 | ### Contents: 8 | 9 | - [Routers](#routers) 10 | - [Contents:](#contents) 11 | - [Context](#context) 12 | - [Protection](#protection) 13 | - [Endpoints](#endpoints) 14 | - [Frontend](#frontend) 15 | 16 | --- 17 | 18 | ### Context 19 | 20 | [Context](./context.ts) allows us to "bundle" together information to be passed into each API request. In our case, NextAuth information and the Prisma client are bundled into each request so that they can be accessed inside the logic of each endpoint. These are provided on the `ctx` object in query/mutation resolvers. Learn more about context [here](https://trpc.io/docs/v9/context). 21 | 22 | ### Protection 23 | 24 | Some routers are initialized with [`createRouter`](./createRouter.ts), while some are initialized with [`createProtectedRouter`](./createProtectedRouter.ts). These signify API routes that can be accessed by any user and routes that can only be accessed by authenticated users, respesctively. No additional logic is required in the endpoints themselves, just a different initialization call. 25 | 26 | ### Endpoints 27 | 28 | tRPC provides two "types" of endpoints: queries and mutations - they're literally the same just naming semantics for some reason LOL. Normally query is used for retrieving data, while mutation is used for... you guessed it modifying data. Our input objects use [Zod](https://zod.dev/) for type validation. Endpoints have the following structure: 29 | 30 | ```typescript 31 | const fooRouter = createRouter() 32 | .query("name", { 33 | // input object using zod 34 | input: z.object({ 35 | userId: z.number().int() 36 | }), 37 | // function that handles api logic 38 | resolve: async ({ ctx, input }) => { 39 | return ctx.prisma.user.findOne({ 40 | where: { id: input.userId}, 41 | select: { name: true } 42 | ) 43 | } 44 | }) 45 | ``` 46 | 47 | ### Frontend 48 | 49 | These endpoints are then accessed in the frontend in the following structure: 50 | 51 | ```typescript 52 | trpc.useQuery( 53 | [ 54 | "endpoint.name", 55 | { 56 | // input object 57 | userId: 100, 58 | }, 59 | ], 60 | { 61 | // what to do if the request is successful 62 | onSuccess: (data) => { 63 | console.log(data.name); 64 | }, 65 | // what to do if the request throws an error 66 | onError: (error) => { 67 | toast.error(`Something went wrong: ${error}`); 68 | }, 69 | } 70 | ); 71 | ``` 72 | -------------------------------------------------------------------------------- /src/server/router/user/recommendations.ts: -------------------------------------------------------------------------------- 1 | import { TRPCError } from "@trpc/server"; 2 | import { protectedRouter, router } from "../createRouter"; 3 | import _ from "lodash"; 4 | import { convertToPublic } from "../../../utils/publicUser"; 5 | import { Status } from "@prisma/client"; 6 | import { calculateScore } from "../../../utils/recommendation"; 7 | import { z } from "zod"; 8 | 9 | // use this router to manage invitations 10 | export const recommendationsRouter = router({ 11 | me: protectedRouter 12 | .input( 13 | z.object({ 14 | sort: z.string(), 15 | filters: z.object({ 16 | days: z.number(), /// 0 for any, 1 for exact 17 | daysWorking: z.string(), 18 | flexDays: z.number(), 19 | startDistance: z.number(), // max 20, greater = any 20 | endDistance: z.number(), 21 | startTime: z.number(), // max = 4 hours, greater = any 22 | endTime: z.number(), 23 | startDate: z.date(), 24 | endDate: z.date(), 25 | dateOverlap: z.number(), // 0 any, 1 partial, 2 full 26 | favorites: z.boolean(), // if true, only show users user has favorited 27 | messaged: z.boolean(), // if false, hide users user has messaged 28 | }), 29 | }) 30 | ) 31 | .query(async ({ input, ctx }) => { 32 | const id = ctx.session.user?.id; 33 | const currentUser = await ctx.prisma.user.findUnique({ 34 | where: { 35 | id: id, 36 | }, 37 | include: { 38 | favorites: input.filters.favorites, 39 | sentRequests: !input.filters.messaged, 40 | receivedRequests: !input.filters.messaged, 41 | }, 42 | }); 43 | if (!currentUser) { 44 | throw new TRPCError({ 45 | code: "NOT_FOUND", 46 | message: `No user with id ${id}.`, 47 | }); 48 | } 49 | const { favorites, sentRequests, receivedRequests, ...calcUser } = 50 | currentUser; 51 | 52 | let userQuery: { id: any; isOnboarded: boolean; status: Status } = { 53 | id: { not: id }, 54 | isOnboarded: true, 55 | status: Status.ACTIVE, 56 | }; 57 | 58 | // Hide users user has messaged 59 | if (!input.filters.messaged) { 60 | userQuery.id["notIn"] = [ 61 | ...sentRequests.map((r) => r.toUserId), 62 | ...receivedRequests.map((r) => r.fromUserId), 63 | ]; 64 | } 65 | 66 | // Favorites filter 67 | if (input.filters.favorites) { 68 | userQuery.id["in"] = favorites.map((f) => f.id); 69 | } 70 | 71 | // Construct Query with Filters 72 | const users = await ctx.prisma.user.findMany({ 73 | where: userQuery, 74 | }); 75 | const recs = _.compact( 76 | users.map(calculateScore(calcUser, input.filters, input.sort)) 77 | ); 78 | recs.sort((a, b) => a.score - b.score); 79 | const sortedUsers = recs.map((rec) => 80 | users.find((user) => user.id === rec.id) 81 | ); 82 | const finalUsers = sortedUsers.slice(0, 50); 83 | 84 | return Promise.all(finalUsers.map((user) => convertToPublic(user!))); 85 | }), 86 | }); 87 | -------------------------------------------------------------------------------- /src/components/Modals/SentRequestModal.tsx: -------------------------------------------------------------------------------- 1 | import { Dialog } from "@headlessui/react"; 2 | import { useState } from "react"; 3 | import { useToasts } from "react-toast-notifications"; 4 | import { EnhancedPublicUser, User } from "../../utils/types"; 5 | import { toast } from "react-toastify"; 6 | import { trpc } from "../../utils/trpc"; 7 | import { Request } from "@prisma/client"; 8 | 9 | interface SentModalProps { 10 | user: User; 11 | otherUser: EnhancedPublicUser; 12 | req: Request; 13 | onClose: () => void; 14 | } 15 | 16 | const SentRequestModal = (props: SentModalProps): JSX.Element => { 17 | const { addToast } = useToasts(); 18 | const [isOpen, setIsOpen] = useState(true); 19 | 20 | const onClose = () => { 21 | setIsOpen(false); 22 | props.onClose(); 23 | }; 24 | 25 | const utils = trpc.useContext(); 26 | const { mutate: deleteRequest } = trpc.user.requests.delete.useMutation({ 27 | onError: (error: any) => { 28 | toast.error(`Something went wrong: ${error.message}`); 29 | }, 30 | onSuccess() { 31 | utils.user.requests.me.invalidate(); 32 | utils.user.recommendations.me.invalidate(); 33 | }, 34 | }); 35 | 36 | const handleWithdrawRequest = () => { 37 | deleteRequest({ 38 | invitationId: props.req.id, 39 | }); 40 | }; 41 | 42 | const handleWithdrawClick = () => { 43 | handleWithdrawRequest(); 44 | onClose(); 45 | addToast( 46 | "Your request to carpool with " + 47 | props.otherUser.preferredName + 48 | " has been withdrawn.", 49 | { appearance: "success" } 50 | ); 51 | }; 52 | 53 | return ( 54 | 55 | {/* backdrop panel */} 56 | 86 | 87 | ); 88 | }; 89 | 90 | export default SentRequestModal; 91 | -------------------------------------------------------------------------------- /src/utils/map/addClusters.ts: -------------------------------------------------------------------------------- 1 | import { Map } from "mapbox-gl"; 2 | import { GeoJsonUsers } from "../types"; 3 | import OrangeSquare from "../../../public/rider-dest.png"; 4 | import RedSquare from "../../../public/driver-dest.png"; 5 | /** 6 | * Filter Expression: https://docs.mapbox.com/mapbox-gl-js/style-spec/expressions/ 7 | * Clusters example with filter expression: https://docs.mapbox.com/mapbox-gl-js/example/cluster-html/ 8 | * Clusters example: https://docs.mapbox.com/mapbox-gl-js/example/cluster/ 9 | */ 10 | 11 | const addClusters = (map: Map, geoJsonUsers: GeoJsonUsers) => { 12 | map.addSource("company-locations", { 13 | type: "geojson", 14 | data: geoJsonUsers, 15 | cluster: true, 16 | clusterMaxZoom: 12, 17 | clusterRadius: 50, 18 | }); 19 | 20 | map.addLayer({ 21 | id: "clusters", 22 | type: "circle", 23 | source: "company-locations", 24 | filter: ["has", "point_count"], 25 | paint: { 26 | "circle-color": [ 27 | "step", 28 | ["get", "point_count"], 29 | "#66c2a5", 30 | 2, 31 | "#51bbd6", 32 | 20, 33 | "#f1f075", 34 | 100, 35 | "#f28cb1", 36 | ], 37 | "circle-radius": [ 38 | "step", 39 | ["get", "point_count"], 40 | 20, 41 | 20, // point count > 20 42 | 30, 43 | 100, // point count > 100 44 | 40, 45 | ], 46 | "circle-stroke-width": 2, 47 | "circle-stroke-color": "#fff", 48 | }, 49 | }); 50 | 51 | map.addLayer({ 52 | id: "cluster-count", 53 | type: "symbol", 54 | source: "company-locations", 55 | filter: ["has", "point_count"], 56 | layout: { 57 | "text-field": "{point_count_abbreviated}", 58 | "text-font": ["DIN Offc Pro Medium", "Arial Unicode MS Bold"], 59 | "text-size": 12, 60 | }, 61 | }); 62 | 63 | // Add Driver Locations 64 | map.loadImage(OrangeSquare.src, (error, orangeImage) => { 65 | if (error || !orangeImage) throw error; 66 | map.addImage("rider-marker", orangeImage); 67 | map.loadImage(RedSquare.src, (error, blueImage) => { 68 | if (error || !blueImage) throw error; 69 | map.addImage("driver-marker", blueImage); 70 | // Layer for Rider markers 71 | map.addLayer({ 72 | id: "riders", 73 | type: "symbol", 74 | source: "company-locations", 75 | filter: [ 76 | "all", 77 | ["!", ["has", "point_count"]], 78 | ["==", ["get", "role"], "RIDER"], 79 | ], 80 | layout: { 81 | "icon-image": "rider-marker", 82 | "icon-allow-overlap": true, 83 | "icon-size": 0.33, 84 | }, 85 | }); 86 | // Layer for Driver markers 87 | map.addLayer({ 88 | id: "drivers", 89 | type: "symbol", 90 | source: "company-locations", 91 | filter: [ 92 | "all", 93 | ["!", ["has", "point_count"]], 94 | ["==", ["get", "role"], "DRIVER"], 95 | ], 96 | layout: { 97 | "icon-image": "driver-marker", 98 | "icon-allow-overlap": true, 99 | "icon-size": 0.33, 100 | }, 101 | }); 102 | }); 103 | }); 104 | }; 105 | 106 | export default addClusters; 107 | -------------------------------------------------------------------------------- /src/components/Profile/ControlledTimePicker.tsx: -------------------------------------------------------------------------------- 1 | import { TimePicker, ConfigProvider } from "antd"; 2 | import dayjs from "dayjs"; 3 | import customParseFormat from "dayjs/plugin/customParseFormat"; 4 | dayjs.extend(customParseFormat); 5 | import utc from "dayjs/plugin/utc"; 6 | dayjs.extend(utc); 7 | import { forwardRef, ReactNode, useEffect, useState } from "react"; 8 | import { Control, Controller, FieldError } from "react-hook-form"; 9 | import { OnboardingFormInputs } from "../../utils/types"; 10 | import { ErrorDisplay } from "../../styles/profile"; 11 | import * as React from "react"; 12 | interface ControlledTimePickerProps { 13 | control: Control; 14 | name: "startTime" | "endTime"; 15 | placeholder?: string; 16 | value?: Date; 17 | isDisabled?: boolean; 18 | error?: FieldError; 19 | } 20 | const ControlledTimePicker = (props: ControlledTimePickerProps) => { 21 | useEffect(() => { 22 | if (props.value) { 23 | dayjs.utc(props.value); 24 | } 25 | }, [props.value]); 26 | 27 | const customSuffixIcon = (): ReactNode => { 28 | return ( 29 |
30 | ▼ 31 |
32 | ); 33 | }; 34 | const TimePickerWrapper = forwardRef< 35 | HTMLDivElement, 36 | React.ComponentProps 37 | >((props, ref) => ( 38 |
39 | 40 |
41 | )); 42 | TimePickerWrapper.displayName = "TimePickerWrapper"; 43 | 44 | return ( 45 | { 49 | return ( 50 | 66 |
67 | { 81 | fieldProps.onChange(date ? date.toDate() : null); 82 | }} 83 | /> 84 | {props.error && ( 85 | {props.error.message} 86 | )} 87 |
88 |
89 | ); 90 | }} 91 | /> 92 | ); 93 | }; 94 | export default ControlledTimePicker; 95 | -------------------------------------------------------------------------------- /src/components/Profile/ProfileSidebar.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import Image from "next/image"; 3 | import user from "../../../public/user.png"; 4 | import car from "../../../public/car.png"; 5 | import checkbox from "../../../public/checkbox.png"; 6 | type ProfileSidebarProps = { 7 | option: "user" | "carpool" | "account"; 8 | setOption: React.Dispatch< 9 | React.SetStateAction<"user" | "carpool" | "account"> 10 | >; 11 | }; 12 | 13 | const ProfileSidebar = ({ option, setOption }: ProfileSidebarProps) => { 14 | const baseButton = 15 | " py-2 relative items-center flex gap-2 text-black font-montserrat text-xl lg:text-2xl "; 16 | const selectedButton = " font-bold !text-northeastern-red"; 17 | return ( 18 |
19 |
20 | 40 | 60 | 61 | 81 |
82 |
83 | ); 84 | }; 85 | export default ProfileSidebar; 86 | -------------------------------------------------------------------------------- /src/utils/profile/zodSchema.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | import { Role, Status } from "@prisma/client"; 3 | 4 | const custom = z.ZodIssueCode.custom; 5 | export const onboardSchema = z 6 | .object({ 7 | role: z.nativeEnum(Role), 8 | status: z.nativeEnum(Status), 9 | seatAvail: z.number().int().nonnegative().max(6).optional(), 10 | companyName: z.string().optional(), 11 | companyAddress: z.string().optional(), 12 | startAddress: z.string().optional(), 13 | preferredName: z.string().optional(), 14 | pronouns: z.string().optional(), 15 | daysWorking: z.array(z.boolean()).optional(), 16 | bio: z.string().optional(), 17 | startTime: z.date().nullable().optional(), 18 | endTime: z.date().nullable().optional(), 19 | coopStartDate: z.date().nullable().optional(), 20 | coopEndDate: z.date().nullable().optional(), 21 | }) 22 | .superRefine((data, ctx) => { 23 | if (data.role !== Role.VIEWER) { 24 | if (!data.coopEndDate) { 25 | ctx.addIssue({ 26 | code: custom, 27 | path: ["coopEndDate"], 28 | message: "Cannot be empty", 29 | }); 30 | } 31 | if (!data.coopStartDate) { 32 | ctx.addIssue({ 33 | code: custom, 34 | path: ["coopStartDate"], 35 | message: "Cannot be empty", 36 | }); 37 | } 38 | if (!data.seatAvail && data.seatAvail !== 0) 39 | ctx.addIssue({ 40 | code: custom, 41 | path: ["seatAvail"], 42 | message: "Cannot be empty", 43 | }); 44 | if (data.companyName?.length === 0) 45 | ctx.addIssue({ 46 | code: custom, 47 | path: ["companyName"], 48 | message: "Cannot be empty", 49 | }); 50 | if (!data.companyAddress || data.companyAddress?.length === 0) 51 | ctx.addIssue({ 52 | code: custom, 53 | path: ["companyAddress"], 54 | message: "Cannot be empty", 55 | }); 56 | if (!data.startAddress || data.startAddress?.length === 0) 57 | ctx.addIssue({ 58 | code: custom, 59 | path: ["startAddress"], 60 | message: "Cannot be empty", 61 | }); 62 | if (!data.daysWorking || !data.daysWorking?.some(Boolean)) 63 | ctx.addIssue({ 64 | code: custom, 65 | path: ["daysWorking"], 66 | message: "Select at least one day", 67 | }); 68 | if (!data.startTime) 69 | ctx.addIssue({ 70 | code: custom, 71 | path: ["startTime"], 72 | message: "Cannot be empty", 73 | }); 74 | if (!data.endTime) 75 | ctx.addIssue({ 76 | code: custom, 77 | path: ["endTime"], 78 | message: "Cannot be empty", 79 | }); 80 | } 81 | }); 82 | export const profileDefaultValues = { 83 | role: Role.RIDER, 84 | status: Status.ACTIVE, 85 | seatAvail: 0, 86 | companyName: "", 87 | profilePicture: "", 88 | companyAddress: "", 89 | startAddress: "", 90 | preferredName: "", 91 | pronouns: "", 92 | daysWorking: [false, false, false, false, false, false, false], 93 | startTime: undefined, 94 | endTime: undefined, 95 | timeDiffers: false, 96 | coopStartDate: null, 97 | coopEndDate: null, 98 | bio: "", 99 | }; 100 | -------------------------------------------------------------------------------- /src/components/UserCards/ConnectCard.tsx: -------------------------------------------------------------------------------- 1 | import { useToasts } from "react-toast-notifications"; 2 | import { 3 | User, 4 | EnhancedPublicUser, 5 | PublicUser, 6 | ButtonInfo, 7 | } from "../../utils/types"; 8 | import { UserCard } from "./UserCard"; 9 | import { useContext, useState } from "react"; 10 | import { createPortal } from "react-dom"; 11 | import ConnectModal from "../Modals/ConnectModal"; 12 | import { UserContext } from "../../utils/userContext"; 13 | import { Role } from "@prisma/client"; 14 | import { trackEvent } from "../../utils/mixpanel"; 15 | 16 | interface ConnectCardProps { 17 | otherUser: EnhancedPublicUser; 18 | onViewRouteClick: (user: User, otherUser: PublicUser) => void; 19 | onClose?: (action: string) => void; 20 | onViewRequest: (userId: string) => void; 21 | } 22 | 23 | export const ConnectCard = (props: ConnectCardProps): JSX.Element => { 24 | const user = useContext(UserContext); 25 | const [showModal, setShowModal] = useState(false); 26 | const { addToast } = useToasts(); 27 | 28 | const handleExistingReceivedRequest = () => { 29 | addToast( 30 | "You already have an incoming carpool request from " + 31 | props.otherUser.preferredName + 32 | ". Navigate to the received requests tab to connect with them!", 33 | { appearance: "info" } 34 | ); 35 | }; 36 | 37 | const handleExistingSentRequest = () => { 38 | addToast( 39 | "You already have an outgoing carpool request to " + 40 | props.otherUser.preferredName + 41 | ". Please wait for them to respond to your request!", 42 | { appearance: "info" } 43 | ); 44 | }; 45 | 46 | const handleNoSeatAvailability = () => { 47 | addToast( 48 | "You do not have any seats available in your car to connect with " + 49 | props.otherUser.preferredName + 50 | ".", 51 | { appearance: "info" } 52 | ); 53 | }; 54 | 55 | const handleConnect = (otherUser: EnhancedPublicUser) => { 56 | trackEvent("Connect Button Clicked", { 57 | userRole: user?.role, 58 | hasIncomingRequest: otherUser.incomingRequest, 59 | hasOutgoingRequest: otherUser.outgoingRequest, 60 | }); 61 | 62 | if (otherUser.incomingRequest) { 63 | handleExistingReceivedRequest(); 64 | } else if (otherUser.outgoingRequest) { 65 | handleExistingSentRequest(); 66 | } else if (user?.role === Role.DRIVER && user.seatAvail === 0) { 67 | handleNoSeatAvailability(); 68 | } else { 69 | setShowModal(true); 70 | } 71 | }; 72 | 73 | const onClose = (action: string) => { 74 | props.onClose?.(action); 75 | setShowModal(false); 76 | }; 77 | 78 | const connectButtonInfo: ButtonInfo = { 79 | text: "Connect", 80 | onPress: () => handleConnect(props.otherUser), 81 | color: "bg-northeastern-red", 82 | }; 83 | return ( 84 | <> 85 | 90 | {showModal && 91 | user && 92 | createPortal( 93 | , 99 | document.body 100 | )} 101 | 102 | ); 103 | }; 104 | -------------------------------------------------------------------------------- /src/components/Messages/MessageHeader.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { EnhancedPublicUser } from "../../utils/types"; 3 | import { AiOutlineUser } from "react-icons/ai"; 4 | import Image from "next/image"; 5 | import useProfileImage from "../../utils/useProfileImage"; 6 | 7 | interface MessageHeaderProps { 8 | selectedUser: EnhancedPublicUser; 9 | onAccept: () => void; 10 | onReject: () => void; 11 | onClose: (userId: string) => void; 12 | groupId: string | null; 13 | } 14 | 15 | const MessageHeader = ({ 16 | selectedUser, 17 | onAccept, 18 | onReject, 19 | onClose, 20 | groupId, 21 | }: MessageHeaderProps) => { 22 | const hasIncomingRequest = !!selectedUser.incomingRequest; 23 | const hasOutgoingRequest = !!selectedUser.outgoingRequest; 24 | 25 | const handleClose = () => { 26 | onClose(""); 27 | }; 28 | const { profileImageUrl, imageLoadError } = useProfileImage(selectedUser.id); 29 | 30 | return ( 31 |
32 |
33 | {profileImageUrl && !imageLoadError ? ( 34 | {`${selectedUser.preferredName}'s 41 | ) : ( 42 | 43 | )} 44 | 45 | 46 | {selectedUser.preferredName} 47 | 48 |
49 |
50 | {hasIncomingRequest && !groupId && ( 51 | <> 52 | 58 | 64 | 65 | )} 66 | {hasOutgoingRequest && !hasIncomingRequest && !groupId && ( 67 | 73 | )} 74 | {groupId && ( 75 | 81 | )} 82 | 83 | 90 |
91 |
92 | ); 93 | }; 94 | 95 | export default MessageHeader; 96 | -------------------------------------------------------------------------------- /src/components/Admin/BarChartUserCounts.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Bar } from "react-chartjs-2"; 3 | import { 4 | Chart as ChartJS, 5 | CategoryScale, 6 | LinearScale, 7 | BarElement, 8 | Title, 9 | Tooltip, 10 | Legend, 11 | ChartData, 12 | ChartOptions, 13 | } from "chart.js"; 14 | 15 | ChartJS.register( 16 | CategoryScale, 17 | LinearScale, 18 | BarElement, 19 | Title, 20 | Tooltip, 21 | Legend 22 | ); 23 | 24 | import { TempUser } from "../../utils/types"; 25 | 26 | interface BarChartOnboardingProps { 27 | users: TempUser[]; 28 | } 29 | 30 | function BarChartUserCounts({ users }: BarChartOnboardingProps) { 31 | const activeUsers = users.filter((user) => user.status === "ACTIVE"); 32 | const totalCount = activeUsers.length; 33 | const inactiveCount = users.length - totalCount; 34 | const countOnboarded = activeUsers.filter((user) => user.isOnboarded).length; 35 | const countNotOnboarded = totalCount - countOnboarded; 36 | const driverCount = activeUsers.filter( 37 | (user) => user.role === "DRIVER" 38 | ).length; 39 | const riderCount = activeUsers.filter((user) => user.role === "RIDER").length; 40 | 41 | const viewerCount = totalCount - (driverCount + riderCount); 42 | const dataPoints = [ 43 | totalCount, 44 | countOnboarded, 45 | countNotOnboarded, 46 | driverCount, 47 | riderCount, 48 | viewerCount, 49 | inactiveCount, 50 | ]; 51 | const barColors = [ 52 | "#000000", 53 | "#FFA9A9", 54 | "#808080", 55 | "#C8102E", 56 | "#DA7D25", 57 | "#2454DD", 58 | "#808080", 59 | ]; 60 | const labels = [ 61 | "Total", 62 | "Onboarded", 63 | "Not Onboarded", 64 | "Driver", 65 | "Rider", 66 | "Viewer", 67 | "Inactive", 68 | ]; 69 | 70 | const barData: ChartData<"bar"> = { 71 | labels, 72 | 73 | datasets: [ 74 | { 75 | label: "Active User Counts", 76 | data: dataPoints, 77 | backgroundColor: barColors, 78 | }, 79 | ], 80 | }; 81 | 82 | const barOptions: ChartOptions<"bar"> = { 83 | responsive: true, 84 | maintainAspectRatio: false, 85 | plugins: { 86 | legend: { 87 | display: false, 88 | }, 89 | title: { 90 | display: true, 91 | text: "User Counts", 92 | font: { 93 | family: "Montserrat", 94 | size: 18, 95 | style: "normal", 96 | weight: "bold", 97 | }, 98 | color: "#000000", 99 | }, 100 | }, 101 | scales: { 102 | x: { 103 | ticks: { 104 | font: { 105 | family: "Montserrat", 106 | size: 16, 107 | style: "normal", 108 | weight: "bold", 109 | }, 110 | }, 111 | }, 112 | y: { 113 | beginAtZero: true, 114 | title: { 115 | display: true, 116 | text: "Number of Users", 117 | font: { 118 | family: "Montserrat", 119 | size: 16, 120 | style: "normal", 121 | weight: "bold", 122 | }, 123 | }, 124 | }, 125 | }, 126 | }; 127 | 128 | return ( 129 |
130 |
131 | 132 | 133 | All bars currently only include active users aside from 134 | "Inactive" 135 | 136 |
137 |
138 | ); 139 | } 140 | 141 | export default BarChartUserCounts; 142 | -------------------------------------------------------------------------------- /src/components/Setup/StepFour.tsx: -------------------------------------------------------------------------------- 1 | import { OnboardingFormInputs } from "../../utils/types"; 2 | import { 3 | FieldErrors, 4 | UseFormRegister, 5 | UseFormSetValue, 6 | UseFormWatch, 7 | } from "react-hook-form"; 8 | import { Note } from "../../styles/profile"; 9 | import { EntryLabel } from "../EntryLabel"; 10 | import { TextField } from "../TextField"; 11 | import ProfilePicture from "../Profile/ProfilePicture"; 12 | 13 | interface StepFourProps { 14 | errors: FieldErrors; 15 | setValue: UseFormSetValue; 16 | register: UseFormRegister; 17 | watch: UseFormWatch; 18 | onFileSelect: (file: File | null) => void; 19 | } 20 | const StepFour = ({ 21 | errors, 22 | register, 23 | onFileSelect, 24 | setValue, 25 | watch, 26 | }: StepFourProps) => { 27 | return ( 28 |
29 |
30 | Who is  31 | carpooling? 32 |
33 | {/* Pfp Section*/} 34 | 35 |
36 | 37 |
38 | 39 | {/* About Me*/} 40 |
41 | {/* Preferred Name field */} 42 | 43 |
44 | 45 | 46 | 54 |
55 | 56 | {/* Pronouns field */} 57 |
58 | 59 | { 68 | const input = e.target; 69 | const cursorPosition = input.selectionStart || 0; 70 | const sanitizedValue = input.value.replace(/[()]/g, ""); 71 | const displayValue = sanitizedValue ? `(${sanitizedValue})` : ""; 72 | setValue("pronouns", sanitizedValue, { shouldValidate: true }); 73 | input.value = displayValue; 74 | // Reset cursor 75 | const adjustedCursor = Math.min( 76 | cursorPosition + 1, 77 | displayValue.length - 1 78 | ); 79 | input.setSelectionRange(adjustedCursor, adjustedCursor); 80 | }} 81 | /> 82 |
83 |
84 | {/* Bio field */} 85 |
86 | 87 |