From 360b54e7c8bdec64052bf6a7d1085e328d215153 Mon Sep 17 00:00:00 2001 From: Nicole Lee Date: Tue, 29 Oct 2024 19:50:26 -0700 Subject: [PATCH 1/2] added ratings --- apps/frontend/src/App.tsx | 9 + .../Class/Ratings/Ratings.module.scss | 449 ++++++++++++++++++ .../src/components/Class/Ratings/index.tsx | 293 ++++++++++++ apps/frontend/src/components/Class/index.tsx | 12 + 4 files changed, 763 insertions(+) create mode 100644 apps/frontend/src/components/Class/Ratings/Ratings.module.scss create mode 100644 apps/frontend/src/components/Class/Ratings/index.tsx diff --git a/apps/frontend/src/App.tsx b/apps/frontend/src/App.tsx index 4fa01c633..0dfe26f78 100644 --- a/apps/frontend/src/App.tsx +++ b/apps/frontend/src/App.tsx @@ -32,6 +32,11 @@ const Course = { }; const About = lazy(() => import("@/app/About")); +const CatalogEnrollment = lazy(() => import("@/components/Class/Enrollment")); +const CatalogGrades = lazy(() => import("@/components/Class/Grades")); +const CatalogOverview = lazy(() => import("@/components/Class/Overview")); +const CatalogSections = lazy(() => import("@/components/Class/Sections")); +const CatalogRatings = lazy(() => import("@/components/Class/Ratings")); const Discover = lazy(() => import("@/app/Discover")); const Plan = lazy(() => import("@/app/Plan")); const Schedule = lazy(() => import("@/app/Schedule")); @@ -143,6 +148,10 @@ const router = createBrowserRouter([ element: , path: "grades", }, + { + element: , + path: "ratings", + }, { path: "*", loader: () => redirect("."), diff --git a/apps/frontend/src/components/Class/Ratings/Ratings.module.scss b/apps/frontend/src/components/Class/Ratings/Ratings.module.scss new file mode 100644 index 000000000..274516fff --- /dev/null +++ b/apps/frontend/src/components/Class/Ratings/Ratings.module.scss @@ -0,0 +1,449 @@ +.root { + background-color: var(--background-color); + padding: 24px 0; +} + +.ratingsContainer { + background-color: var(--foreground-color); + border-radius: 8px; + box-shadow: 0 1px 2px rgb(0 0 0 / 5%); + border: 1px solid var(--border-color); + +} + +.ratingSection { + padding: 16px 24px; + border-bottom: 1px solid var(--border-color); + + &:last-child { + border-bottom: none; + } +} + +.ratingHeader { + display: flex; + justify-content: space-between; + align-items: center; + cursor: pointer; + + &:hover { + .arrow { + color: var(--heading-color); + } + } +} + +.titleSection { + display: flex; + align-items: center; + gap: 8px; + + .title { + color: var(--heading-color); + font-size: 16px; + font-weight: 500; + } + + .info { + color: var(--label-color); + font-size: 14px; + + &:hover { + color: var(--heading-color); + } + } +} + +.tooltipContent { + background-color: rgb(0 0 0); + border-radius: 4px; + padding: 12px; + padding-top: 0.1px; + max-width: 260px; + box-shadow: 0 2px 4px rgb(0 0 0 / 10%); +} + +.tooltipTitle { + color: white; + font-size: 14px; + font-weight: 500; + margin-bottom: 4px; +} + +.tooltipDescription { + color: var(--paragraph-color); + font-size: 13px; + line-height: 1.4; +} + +.arrow { + fill: var(--foreground-color); +} + +.statusSection { + display: flex; + align-items: center; + gap: 12px; +} + +.reviewCount { + color: var(--label-color); + font-size: 14px; +} + +.arrow { + color: var(--label-color); + transition: transform 0.2s ease; + + &.expanded { + transform: rotate(180deg); + } +} + +.statusGreen { + color: var(--green-600); +} + +.statusOrange { + color: var(--orange-500); +} + +.statusRed { + color: var(--red-600); +} + +.ratingContent { + margin-top: 16px; + margin-left: 25%; +} + +.statRow { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 8px; + + &:last-child { + margin-bottom: 0; + } + + .rating { + width: 24px; + color: var(--heading-color); + font-weight: 500; + text-align: center; + } + + .barContainer { + flex-grow: 1; + height: 8px; + background-color: var(--background-color); + border-radius: 4px; + overflow: hidden; + + .bar { + height: 100%; + background-color: var(--blue-500); + border-radius: 4px; + transition: width 0.3s ease; + } + } + + .percentage { + width: 48px; + color: var(--paragraph-color); + font-size: 14px; + text-align: right; + } +} + +.header { + margin-bottom: 16px; +} + +.overlay { + background-color: rgb(0 0 0 / 50%); + position: fixed; + inset: 0; + animation: overlayShow 150ms cubic-bezier(0.16, 1, 0.3, 1); + z-index: 50; /* Ensure overlay is above other content */ +} + + +.modal { + background-color: var(--foreground-color); + border-radius: 8px; + box-shadow: 0 4px 32px rgb(0 0 0 / 25%); + position: fixed; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + width: 90vw; + max-width: 600px; + max-height: 85vh; + padding: 24px; + animation: contentShow 150ms cubic-bezier(0.16, 1, 0.3, 1); + overflow-y: auto; + z-index: 51; /* Ensure modal is above the overlay */ +} + +.modalHeader { + margin-bottom: 24px; + text-align: left; +} + +.modalTitle { + color: var(--heading-color); + font-size: 24px; + font-weight: 500; + margin-bottom: 4px; +} + +.modalSubtitle { + color: var(--paragraph-color); + font-size: 16px; +} + +.modalContent { + margin-bottom: 24px; +} + +.ratingQuestion { + margin-bottom: 24px; + + h3 { + color: var(--heading-color); + font-size: 16px; + font-weight: 500; + margin-bottom: 16px; + } +} + +.ratingScale { + display: flex; + align-items: center; + gap: 16px; + margin-top: 8px; + + span { + color: var(--paragraph-color); + font-size: 14px; + min-width: 80px; + } +} + +.ratingButtons { + display: flex; + gap: 8px; + flex-grow: 1; + justify-content: center; +} + +.ratingButton { + width: 40px; + height: 40px; + border: 1px solid var(--border-color); + border-radius: 4px; + background: none; + color: var(--heading-color); + font-weight: 500; + cursor: pointer; + transition: all 0.2s ease; + + &:hover { + background-color: var(--background-hover-color); + } + + &[data-state='checked'] { + background-color: var(--blue-500); + border-color: var(--blue-500); + color: white; + } +} + +.radioGroup { + display: flex; + flex-direction: column; + gap: 12px; + margin-top: 8px; + + label { + display: flex; + align-items: center; + gap: 8px; + color: var(--paragraph-color); + font-size: 14px; + cursor: pointer; + + input { + width: 16px; + height: 16px; + } + } +} + +.modalFooter { + display: flex; + justify-content: flex-end; + gap: 12px; + border-top: 1px solid var(--border-color); + padding-top: 24px; +} + +@keyframes overlayShow { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +@keyframes contentShow { + from { + opacity: 0; + transform: translate(-50%, -48%) scale(0.96); + } + to { + opacity: 1; + transform: translate(-50%, -50%) scale(1); + } +} + +.ratingButton { + width: 40px; + height: 40px; + border: 1px solid var(--border-color); + border-radius: 4px; + background: none; + color: var(--heading-color); + font-weight: 500; + cursor: pointer; + transition: all 0.2s ease; + + &:hover { + background-color: var(--background-hover-color); + border-color: var(--blue-500); + } + + &.selected { + background-color: var(--blue-500); + border-color: var(--blue-500); + color: white; + } + + &:focus { + outline: none; + box-shadow: 0 0 0 2px var(--blue-200); + } +} + +@keyframes contentSlide { + from { + opacity: 0; + transform: translateY(-10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes fadeIn { + from { + opacity: 0; + transform: translateX(-10px); + } + to { + opacity: 1; + transform: translateX(0); + } +} + +.ratingContent { + margin-top: 16px; + margin-left: 25%; + animation: slideDown 300ms ease forwards; +} + +.statRow { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 8px; + opacity: 0; + animation: fadeIn 500ms ease forwards; + animation-delay: var(--delay); + + &:last-child { + margin-bottom: 0; + } + + .rating { + width: 24px; + color: var(--heading-color); + font-weight: 500; + text-align: center; + opacity: 0; + animation: fadeIn 300ms ease forwards; + animation-delay: calc(var(--delay) + 100ms); + } + + .barContainer { + flex-grow: 1; + height: 8px; + background-color: var(--background-color); + border-radius: 4px; + overflow: hidden; + + .bar { + height: 100%; + background-color: var(--blue-500); + border-radius: 4px; + transition: width 1000ms cubic-bezier(0.4, 0, 0.2, 1); + width: 0; + } + } + + .percentage { + width: 48px; + color: var(--paragraph-color); + font-size: 14px; + text-align: right; + opacity: 0; + animation: fadeIn 300ms ease forwards; + animation-delay: calc(var(--delay) + 200ms); + transition: all 1000ms cubic-bezier(0.4, 0, 0.2, 1); + } +} + +@keyframes slideDown { + from { + opacity: 0; + transform: translateY(-20px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes fadeIn { + from { + opacity: 0; + transform: translateX(-10px); + } + to { + opacity: 1; + transform: translateX(0); + } +} + +@keyframes barFill { + from { + transform: scaleX(0); + } + to { + transform: scaleX(1); + } +} \ No newline at end of file diff --git a/apps/frontend/src/components/Class/Ratings/index.tsx b/apps/frontend/src/components/Class/Ratings/index.tsx new file mode 100644 index 000000000..030a8f8b0 --- /dev/null +++ b/apps/frontend/src/components/Class/Ratings/index.tsx @@ -0,0 +1,293 @@ +import React, { useState, useContext, useEffect } from 'react'; +import { NavArrowDown } from 'iconoir-react'; +import * as Tooltip from "@radix-ui/react-tooltip"; +import * as Dialog from "@radix-ui/react-dialog"; +import { Container, Button } from "@repo/theme"; +import styles from './Ratings.module.scss'; +import ClassContext from "@/contexts/ClassContext"; + +interface RatingDetailProps { + title: string; + tooltip: string; + stats: { + rating: number; + percentage: number; + }[]; + status: string; + statusColor: string; + reviewCount: number; +} + +const RatingDetail: React.FC = ({ + title, + tooltip, + stats, + status, + statusColor, + reviewCount +}) => { + const [isExpanded, setIsExpanded] = useState(true); + const [shouldAnimate, setShouldAnimate] = useState(true); + + // Start animation slightly after expansion + useEffect(() => { + if (isExpanded) { + const timer = setTimeout(() => { + setShouldAnimate(true); + }, 200); // Delay to match the slideDown animation + return () => { + clearTimeout(timer); + setShouldAnimate(false); + }; + } + }, [isExpanded]); + + return ( +
+
setIsExpanded(!isExpanded)} + > +
+

{title}

+ + + + + + + + + + + +
+
+ {status} + + ({reviewCount} reviews) + + +
+
+ + {isExpanded && ( +
+ {stats.map((stat, index) => ( +
+ {stat.rating} +
+
+
+ + {shouldAnimate ? `${stat.percentage}%` : '0%'} + +
+ ))} +
+ )} +
+ ); +}; + +interface TooltipContentProps { + title: string; + description: string; +} + +const TooltipContent: React.FC = ({ title, description }) => ( +
+

{title}

+

{description}

+
+); + +function RatingModal() { + const { class: currentClass } = useContext(ClassContext); + const [ratings, setRatings] = useState({ + usefulness: 0, + difficulty: 0, + workload: 0 + }); + + const handleRatingClick = (type: 'usefulness' | 'difficulty' | 'workload', value: number) => { + setRatings(prev => ({ + ...prev, + [type]: value, + [type]: prev[type] === value ? 0 : value + })); + }; + + const getRatingButtonClass = (type: 'usefulness' | 'difficulty' | 'workload', value: number) => { + return `${styles.ratingButton} ${ratings[type] === value ? styles.selected : ''}`; + }; + + return ( + + + +
+ + Rate Course + + + {currentClass.subject} {currentClass.courseNumber} • {currentClass.semester} {currentClass.year} + +
+ +
+
+

1. How would you rate the usefulness of this course?

+
+ Not useful +
+ {[1, 2, 3, 4, 5].map((value) => ( + + ))} +
+ Very useful +
+
+ +
+

2. How would you rate the difficulty of this course?

+
+ Very easy +
+ {[1, 2, 3, 4, 5].map((value) => ( + + ))} +
+ Very difficult +
+
+ +
+

3. How would you rate the workload of this course?

+
+ Very light +
+ {[1, 2, 3, 4, 5].map((value) => ( + + ))} +
+ Very heavy +
+
+
+ +
+ + + + +
+
+
+ ); +} + +export default function Ratings() { + const ratingsData = [ + { + title: "Usefulness", + tooltip: "This refers to how beneficial a course is in helping students achieve their academic, professional, or personal goals.", + stats: [ + { rating: 5, percentage: 56 }, + { rating: 4, percentage: 16 }, + { rating: 3, percentage: 11 }, + { rating: 2, percentage: 6 }, + { rating: 1, percentage: 11 } + ], + status: "Very Useful", + statusColor: "statusGreen", + reviewCount: 218 + }, + { + title: "Difficulty", + tooltip: "This indicates the level of challenge students experience in understanding and completing course material.", + stats: [ + { rating: 5, percentage: 30 }, + { rating: 4, percentage: 40 }, + { rating: 3, percentage: 20 }, + { rating: 2, percentage: 5 }, + { rating: 1, percentage: 5 } + ], + status: "Moderately Difficult", + statusColor: "statusOrange", + reviewCount: 218 + }, + { + title: "Workload", + tooltip: "This represents the time and effort required to complete course assignments, readings, and other activities.", + stats: [ + { rating: 5, percentage: 25 }, + { rating: 4, percentage: 35 }, + { rating: 3, percentage: 25 }, + { rating: 2, percentage: 10 }, + { rating: 1, percentage: 5 } + ], + status: "Moderately Workload", + statusColor: "statusOrange", + reviewCount: 218 + } + ]; + + return ( +
+ +
+ + + + + + +
+
+ {ratingsData.map((ratingData) => ( + + ))} +
+
+
+ ); +} \ No newline at end of file diff --git a/apps/frontend/src/components/Class/index.tsx b/apps/frontend/src/components/Class/index.tsx index ad35e7ebb..3a2391d87 100644 --- a/apps/frontend/src/components/Class/index.tsx +++ b/apps/frontend/src/components/Class/index.tsx @@ -43,6 +43,7 @@ import Enrollment from "./Enrollment"; import Grades from "./Grades"; import Overview from "./Overview"; import Sections from "./Sections"; +import Ratings from "./Ratings"; interface BodyProps { children: ReactNode; @@ -320,6 +321,9 @@ export default function Class({ Grades + + Ratings + ) : (
@@ -343,6 +347,11 @@ export default function Class({ Grades )} + + {({ isActive }) => ( + Ratings + )} +
)} @@ -366,6 +375,9 @@ export default function Class({ + + + From f7384451493635d990ce17e5e4694dc8e446552c Mon Sep 17 00:00:00 2001 From: Nicole Lee Date: Wed, 30 Oct 2024 03:13:17 -0700 Subject: [PATCH 2/2] made progress on adding ratings/data --- .../src/components/Class/Overview/index.tsx | 35 +-- .../Class/Ratings/Ratings.module.scss | 215 ++------------ .../src/components/Class/Ratings/index.tsx | 170 +++-------- .../src/components/Detail/Detail.module.scss | 35 +++ apps/frontend/src/components/Detail/index.tsx | 52 ++++ .../UserFeedbackModal/AttendanceForm.tsx | 56 ++++ .../UserFeedbackModal/RatingForm.tsx | 76 +++++ .../UserFeedbackModal.module.scss | 278 ++++++++++++++++++ .../UserFeedbackModal/UserFeedbackModal.tsx | 67 +++++ .../src/components/UserFeedbackModal/index.ts | 2 + .../src/components/UserFeedbackModal/types.ts | 6 + apps/frontend/src/lib/api/classes.ts | 2 + 12 files changed, 653 insertions(+), 341 deletions(-) create mode 100644 apps/frontend/src/components/Detail/Detail.module.scss create mode 100644 apps/frontend/src/components/Detail/index.tsx create mode 100644 apps/frontend/src/components/UserFeedbackModal/AttendanceForm.tsx create mode 100644 apps/frontend/src/components/UserFeedbackModal/RatingForm.tsx create mode 100644 apps/frontend/src/components/UserFeedbackModal/UserFeedbackModal.module.scss create mode 100644 apps/frontend/src/components/UserFeedbackModal/UserFeedbackModal.tsx create mode 100644 apps/frontend/src/components/UserFeedbackModal/index.ts create mode 100644 apps/frontend/src/components/UserFeedbackModal/types.ts diff --git a/apps/frontend/src/components/Class/Overview/index.tsx b/apps/frontend/src/components/Class/Overview/index.tsx index c731dc8ed..0ba3c3041 100644 --- a/apps/frontend/src/components/Class/Overview/index.tsx +++ b/apps/frontend/src/components/Class/Overview/index.tsx @@ -1,24 +1,21 @@ import Details from "@/components/Details"; import useClass from "@/hooks/useClass"; - import styles from "./Overview.module.scss"; +import AttendanceRequirements from "@/components/Detail"; export default function Overview() { - const { class: _class } = useClass(); - - return ( -
-
-

Description

-

- {_class.description ?? _class.course.description} -

- {_class.course.requirements && ( - <> -

Prerequisites

-

{_class.course.requirements}

- - )} -
- ); -} + const { class: _class } = useClass(); + return ( +
+
+

Description

+

+ {_class.description ?? _class.course.description} +

+ +
+ ); +} \ No newline at end of file diff --git a/apps/frontend/src/components/Class/Ratings/Ratings.module.scss b/apps/frontend/src/components/Class/Ratings/Ratings.module.scss index 274516fff..b0e936a9b 100644 --- a/apps/frontend/src/components/Class/Ratings/Ratings.module.scss +++ b/apps/frontend/src/components/Class/Ratings/Ratings.module.scss @@ -8,7 +8,6 @@ border-radius: 8px; box-shadow: 0 1px 2px rgb(0 0 0 / 5%); border: 1px solid var(--border-color); - } .ratingSection { @@ -115,6 +114,7 @@ .ratingContent { margin-top: 16px; margin-left: 25%; + animation: slideDown 300ms ease forwards; } .statRow { @@ -122,6 +122,9 @@ align-items: center; gap: 12px; margin-bottom: 8px; + opacity: 0; + animation: fadeIn 500ms ease forwards; + animation-delay: var(--delay); &:last-child { margin-bottom: 0; @@ -132,6 +135,9 @@ color: var(--heading-color); font-weight: 500; text-align: center; + opacity: 0; + animation: fadeIn 300ms ease forwards; + animation-delay: calc(var(--delay) + 100ms); } .barContainer { @@ -145,7 +151,8 @@ height: 100%; background-color: var(--blue-500); border-radius: 4px; - transition: width 0.3s ease; + transition: width 1000ms cubic-bezier(0.4, 0, 0.2, 1); + width: 0; } } @@ -154,6 +161,10 @@ color: var(--paragraph-color); font-size: 14px; text-align: right; + opacity: 0; + animation: fadeIn 300ms ease forwards; + animation-delay: calc(var(--delay) + 200ms); + transition: all 1000ms cubic-bezier(0.4, 0, 0.2, 1); } } @@ -161,202 +172,34 @@ margin-bottom: 16px; } -.overlay { - background-color: rgb(0 0 0 / 50%); - position: fixed; - inset: 0; - animation: overlayShow 150ms cubic-bezier(0.16, 1, 0.3, 1); - z-index: 50; /* Ensure overlay is above other content */ -} - - -.modal { - background-color: var(--foreground-color); - border-radius: 8px; - box-shadow: 0 4px 32px rgb(0 0 0 / 25%); - position: fixed; - top: 50%; - left: 50%; - transform: translate(-50%, -50%); - width: 90vw; - max-width: 600px; - max-height: 85vh; - padding: 24px; - animation: contentShow 150ms cubic-bezier(0.16, 1, 0.3, 1); - overflow-y: auto; - z-index: 51; /* Ensure modal is above the overlay */ -} - -.modalHeader { - margin-bottom: 24px; - text-align: left; -} - -.modalTitle { - color: var(--heading-color); - font-size: 24px; - font-weight: 500; - margin-bottom: 4px; -} - -.modalSubtitle { - color: var(--paragraph-color); - font-size: 16px; -} - -.modalContent { - margin-bottom: 24px; -} - -.ratingQuestion { - margin-bottom: 24px; - - h3 { - color: var(--heading-color); - font-size: 16px; - font-weight: 500; - margin-bottom: 16px; - } -} - -.ratingScale { - display: flex; - align-items: center; - gap: 16px; - margin-top: 8px; - - span { - color: var(--paragraph-color); - font-size: 14px; - min-width: 80px; - } -} - -.ratingButtons { - display: flex; - gap: 8px; - flex-grow: 1; - justify-content: center; -} - -.ratingButton { - width: 40px; - height: 40px; - border: 1px solid var(--border-color); - border-radius: 4px; - background: none; - color: var(--heading-color); - font-weight: 500; - cursor: pointer; - transition: all 0.2s ease; - - &:hover { - background-color: var(--background-hover-color); - } - - &[data-state='checked'] { - background-color: var(--blue-500); - border-color: var(--blue-500); - color: white; - } -} - -.radioGroup { - display: flex; - flex-direction: column; - gap: 12px; - margin-top: 8px; - - label { - display: flex; - align-items: center; - gap: 8px; - color: var(--paragraph-color); - font-size: 14px; - cursor: pointer; - - input { - width: 16px; - height: 16px; - } - } -} - -.modalFooter { - display: flex; - justify-content: flex-end; - gap: 12px; - border-top: 1px solid var(--border-color); - padding-top: 24px; -} - -@keyframes overlayShow { - from { - opacity: 0; - } - to { - opacity: 1; - } -} - -@keyframes contentShow { +@keyframes slideDown { from { opacity: 0; - transform: translate(-50%, -48%) scale(0.96); + transform: translateY(-20px); } to { opacity: 1; - transform: translate(-50%, -50%) scale(1); - } -} - -.ratingButton { - width: 40px; - height: 40px; - border: 1px solid var(--border-color); - border-radius: 4px; - background: none; - color: var(--heading-color); - font-weight: 500; - cursor: pointer; - transition: all 0.2s ease; - - &:hover { - background-color: var(--background-hover-color); - border-color: var(--blue-500); - } - - &.selected { - background-color: var(--blue-500); - border-color: var(--blue-500); - color: white; - } - - &:focus { - outline: none; - box-shadow: 0 0 0 2px var(--blue-200); + transform: translateY(0); } } -@keyframes contentSlide { +@keyframes fadeIn { from { opacity: 0; - transform: translateY(-10px); + transform: translateX(-10px); } to { opacity: 1; - transform: translateY(0); + transform: translateX(0); } } -@keyframes fadeIn { +@keyframes barFill { from { - opacity: 0; - transform: translateX(-10px); + transform: scaleX(0); } to { - opacity: 1; - transform: translateX(0); + transform: scaleX(1); } } @@ -400,8 +243,10 @@ height: 100%; background-color: var(--blue-500); border-radius: 4px; - transition: width 1000ms cubic-bezier(0.4, 0, 0.2, 1); width: 0; + transform-origin: left; + transition: width 600ms cubic-bezier(0.4, 0, 0.2, 1); + will-change: width; } } @@ -413,7 +258,6 @@ opacity: 0; animation: fadeIn 300ms ease forwards; animation-delay: calc(var(--delay) + 200ms); - transition: all 1000ms cubic-bezier(0.4, 0, 0.2, 1); } } @@ -437,13 +281,4 @@ opacity: 1; transform: translateX(0); } -} - -@keyframes barFill { - from { - transform: scaleX(0); - } - to { - transform: scaleX(1); - } } \ No newline at end of file diff --git a/apps/frontend/src/components/Class/Ratings/index.tsx b/apps/frontend/src/components/Class/Ratings/index.tsx index 030a8f8b0..71f2844d7 100644 --- a/apps/frontend/src/components/Class/Ratings/index.tsx +++ b/apps/frontend/src/components/Class/Ratings/index.tsx @@ -1,10 +1,22 @@ import React, { useState, useContext, useEffect } from 'react'; import { NavArrowDown } from 'iconoir-react'; import * as Tooltip from "@radix-ui/react-tooltip"; -import * as Dialog from "@radix-ui/react-dialog"; import { Container, Button } from "@repo/theme"; -import styles from './Ratings.module.scss'; +import { UserFeedbackModal } from '@/components/UserFeedbackModal'; import ClassContext from "@/contexts/ClassContext"; +import styles from './Ratings.module.scss'; + +interface TooltipContentProps { + title: string; + description: string; +} + +const TooltipContent: React.FC = ({ title, description }) => ( +
+

{title}

+

{description}

+
+); interface RatingDetailProps { title: string; @@ -27,19 +39,22 @@ const RatingDetail: React.FC = ({ reviewCount }) => { const [isExpanded, setIsExpanded] = useState(true); - const [shouldAnimate, setShouldAnimate] = useState(true); + const [shouldAnimate, setShouldAnimate] = useState(false); - // Start animation slightly after expansion useEffect(() => { + let timer: NodeJS.Timeout; if (isExpanded) { - const timer = setTimeout(() => { - setShouldAnimate(true); - }, 200); // Delay to match the slideDown animation - return () => { - clearTimeout(timer); - setShouldAnimate(false); - }; + setShouldAnimate(false); + // Using requestAnimationFrame for smoother animation + requestAnimationFrame(() => { + timer = setTimeout(() => { + setShouldAnimate(true); + }, 50); + }); } + return () => { + if (timer) clearTimeout(timer); + }; }, [isExpanded]); return ( @@ -92,7 +107,7 @@ const RatingDetail: React.FC = ({ className={styles.bar} style={{ width: shouldAnimate ? `${stat.percentage}%` : '0%', - transitionDelay: `${index * 100}ms` + transitionDelay: `${index * 60}ms` }} />
@@ -107,122 +122,10 @@ const RatingDetail: React.FC = ({ ); }; -interface TooltipContentProps { - title: string; - description: string; -} - -const TooltipContent: React.FC = ({ title, description }) => ( -
-

{title}

-

{description}

-
-); - -function RatingModal() { +export default function Ratings() { + const [isModalOpen, setModalOpen] = useState(false); const { class: currentClass } = useContext(ClassContext); - const [ratings, setRatings] = useState({ - usefulness: 0, - difficulty: 0, - workload: 0 - }); - - const handleRatingClick = (type: 'usefulness' | 'difficulty' | 'workload', value: number) => { - setRatings(prev => ({ - ...prev, - [type]: value, - [type]: prev[type] === value ? 0 : value - })); - }; - - const getRatingButtonClass = (type: 'usefulness' | 'difficulty' | 'workload', value: number) => { - return `${styles.ratingButton} ${ratings[type] === value ? styles.selected : ''}`; - }; - - return ( - - - -
- - Rate Course - - - {currentClass.subject} {currentClass.courseNumber} • {currentClass.semester} {currentClass.year} - -
- -
-
-

1. How would you rate the usefulness of this course?

-
- Not useful -
- {[1, 2, 3, 4, 5].map((value) => ( - - ))} -
- Very useful -
-
- -
-

2. How would you rate the difficulty of this course?

-
- Very easy -
- {[1, 2, 3, 4, 5].map((value) => ( - - ))} -
- Very difficult -
-
- -
-

3. How would you rate the workload of this course?

-
- Very light -
- {[1, 2, 3, 4, 5].map((value) => ( - - ))} -
- Very heavy -
-
-
-
- - - - -
-
-
- ); -} - -export default function Ratings() { const ratingsData = [ { title: "Usefulness", @@ -272,12 +175,7 @@ export default function Ratings() {
- - - - - - +
{ratingsData.map((ratingData) => ( @@ -287,6 +185,14 @@ export default function Ratings() { /> ))}
+ + setModalOpen(false)} + title="Rate Course" + subtitle={`${currentClass.subject} ${currentClass.courseNumber} • ${currentClass.semester} ${currentClass.year}`} + currentClass={currentClass} + />
); diff --git a/apps/frontend/src/components/Detail/Detail.module.scss b/apps/frontend/src/components/Detail/Detail.module.scss new file mode 100644 index 000000000..b0e009047 --- /dev/null +++ b/apps/frontend/src/components/Detail/Detail.module.scss @@ -0,0 +1,35 @@ +.label { + margin-top: 20px; + margin-bottom: 10px; + color: var(--label-color); + line-height: 1; +} + +.description { + color: var(--paragraph-color); + font-size: 14px; + margin-top: 6px; + line-height: 1.5; +} + +.attendanceRequirements { + .icon { + margin-right: 8px; + font-size: 1.2rem; + color: #6c757d; + vertical-align: middle; + } +} + +.suggestEdit { + color: var(--blue-500); + text-decoration: none; + font-size: 0.9rem; + margin-top: 10px; + display: flex; + align-items: center; + + &:hover { + text-decoration: underline; + } +} \ No newline at end of file diff --git a/apps/frontend/src/components/Detail/index.tsx b/apps/frontend/src/components/Detail/index.tsx new file mode 100644 index 000000000..34186d7bb --- /dev/null +++ b/apps/frontend/src/components/Detail/index.tsx @@ -0,0 +1,52 @@ +import { useState } from "react"; +import { UserCircle, Camera } from "iconoir-react"; +import { UserFeedbackModal } from '@/components/UserFeedbackModal'; +import useClass from "@/hooks/useClass"; +import styles from "./Detail.module.scss"; + +interface AttendanceRequirementsProps { + attendanceRequired: boolean | null; + lecturesRecorded: boolean | null; +} + +export default function AttendanceRequirements({ + attendanceRequired, + lecturesRecorded, +}: AttendanceRequirementsProps) { + const [isModalOpen, setModalOpen] = useState(false); + const { class: currentClass } = useClass(); + + return ( +
+

Attendance Requirements

+
+ + + {attendanceRequired ? "Attendance Required" : "Attendance Not Required"} + +
+
+ + {lecturesRecorded ? "Lectures Recorded" : "Lectures Not Recorded"} +
+ { + e.preventDefault(); + setModalOpen(true); + }} + > + Look inaccurate? Suggest an edit + + + setModalOpen(false)} + title="Suggest an edit" + subtitle={`${currentClass.subject} ${currentClass.courseNumber} • ${currentClass.semester} ${currentClass.year}`} + currentClass={currentClass} + /> +
+ ); +} \ No newline at end of file diff --git a/apps/frontend/src/components/UserFeedbackModal/AttendanceForm.tsx b/apps/frontend/src/components/UserFeedbackModal/AttendanceForm.tsx new file mode 100644 index 000000000..10a91f63b --- /dev/null +++ b/apps/frontend/src/components/UserFeedbackModal/AttendanceForm.tsx @@ -0,0 +1,56 @@ +import React from 'react'; +import styles from './UserFeedbackModal.module.scss'; +import { ClassData } from './types'; + +interface AttendanceFormProps { + currentClass: ClassData; +} + +export function AttendanceForm({ currentClass }: AttendanceFormProps) { + return ( +
+

Attendance & Recording

+
+

1. Is lecture attendance required?

+
+ + +
+
+ +
+

2. (If applicable) Was discussion attendance required?

+
+ + +
+
+ +
+

3. Were lectures recorded?

+
+ + +
+
+
+ ); +} diff --git a/apps/frontend/src/components/UserFeedbackModal/RatingForm.tsx b/apps/frontend/src/components/UserFeedbackModal/RatingForm.tsx new file mode 100644 index 000000000..f25151d67 --- /dev/null +++ b/apps/frontend/src/components/UserFeedbackModal/RatingForm.tsx @@ -0,0 +1,76 @@ +import React, { useState } from 'react'; +import styles from './UserFeedbackModal.module.scss'; +import { ClassData } from './types'; + +interface RatingFormProps { + currentClass: ClassData; +} + +export function RatingsForm({ currentClass }: RatingFormProps) { + const [ratings, setRatings] = useState({ + usefulness: 0, + difficulty: 0, + workload: 0 + }); + + const handleRatingClick = (type: keyof typeof ratings, value: number) => { + setRatings(prev => ({ + ...prev, + [type]: prev[type] === value ? 0 : value + })); + }; + + const renderRatingScale = ( + type: keyof typeof ratings, + question: string, + leftLabel: string, + rightLabel: string + ) => ( +
+

{question}

+
+ {leftLabel} +
+ {[1, 2, 3, 4, 5].map((value) => ( + + ))} +
+ {rightLabel} +
+
+ ); + + return ( +
+

Course Ratings

+ + {renderRatingScale( + 'usefulness', + '1. How would you rate the usefulness of this course?', + 'Not useful', + 'Very useful' + )} + + {renderRatingScale( + 'difficulty', + '2. How would you rate the difficulty of this course?', + 'Very easy', + 'Very difficult' + )} + + {renderRatingScale( + 'workload', + '3. How would you rate the workload of this course?', + 'Very light', + 'Very heavy' + )} +
+ ); +} diff --git a/apps/frontend/src/components/UserFeedbackModal/UserFeedbackModal.module.scss b/apps/frontend/src/components/UserFeedbackModal/UserFeedbackModal.module.scss new file mode 100644 index 000000000..33de85abe --- /dev/null +++ b/apps/frontend/src/components/UserFeedbackModal/UserFeedbackModal.module.scss @@ -0,0 +1,278 @@ +.overlay { + background-color: rgb(0 0 0 / 60%); + position: fixed; + inset: 0; + animation: overlayShow 150ms cubic-bezier(0.16, 1, 0.3, 1); + z-index: 50; +} + +.modal { + background-color: var(--foreground-color); + border-radius: 8px; + box-shadow: 0 4px 32px rgb(0 0 0 / 25%); + position: fixed; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + width: 90vw; + max-width: 600px; + max-height: 85vh; + padding: 24px; + animation: contentShow 150ms cubic-bezier(0.16, 1, 0.3, 1); + z-index: 51; +} + +.modalHeader { + margin-bottom: 24px; + text-align: left; +} + +.modalTitle { + color: var(--heading-color); + font-size: 24px; + font-weight: 500; + margin-bottom: 12px; +} + +.subtitleRow { + display: flex; + align-items: center; + gap: 12px; +} + +.modalSubtitle { + color: var(--paragraph-color); + font-size: 16px; + margin: 0; +} + +.modalContent { + margin-bottom: 0px; +} + +.combinedForm { + max-height: calc(85vh - 220px); + overflow-y: auto; + padding-right: 12px; + margin-right: -12px; + margin-top:0; + + &::-webkit-scrollbar { + width: 6px; + height: 6px; + } + + &::-webkit-scrollbar-track { + background: var(--background-color); + border-radius: 3px; + } + + &::-webkit-scrollbar-thumb { + background: var(--label-color); + border-radius: 3px; + opacity: 0.8; + + &:hover { + background: var(--paragraph-color); + } + } +} + +.termSelect { + display: none; +} + +.termDropdown { + padding: 4px 12px; + font-size: 14px; + font-weight: 400; + color: var(--heading-color); + background-color: var(--foreground-color); + border: 1px solid var(--border-color); + border-radius: 4px; + outline: none; + cursor: pointer; + transition: all 0.2s ease; + min-width: 120px; + + &:hover { + border-color: var(--blue-400); + } + + &:focus { + border-color: var(--blue-500); + box-shadow: 0 0 0 2px var(--blue-200); + } +} + +.modalFooter { + position: sticky; + + margin-top: 32px; + padding-top: 24px; + border-top: 1px solid var(--border-color); + display: flex; + justify-content: flex-end; + gap: 12px; +} + +.sectionTitle { + color: var(--heading-color); + font-size: 18px; + font-weight: 500; + margin-bottom: 20px; + padding-bottom: 12px; + border-bottom: 1px solid var(--border-color); +} + +.formGroup { + margin-bottom: 32px; + + &:last-child { + margin-bottom: 40px; + } + + h3, p { + color: var(--heading-color); + font-size: 16px; + font-weight: 500; + margin-bottom: 12px; + } +} + +.ratingScale { + display: flex; + align-items: center; + gap: 16px; + margin-top: 8px; + + span { + color: var(--paragraph-color); + font-size: 14px; + min-width: 80px; + } +} + +.ratingButtons { + display: flex; + gap: 8px; + flex-grow: 1; + justify-content: center; +} + +.ratingButton { + width: 40px; + height: 40px; + border: 1px solid var(--border-color); + border-radius: 4px; + background: none; + color: var(--heading-color); + font-weight: 500; + cursor: pointer; + transition: all 0.2s ease; + + &:hover { + background-color: var(--background-hover-color); + border-color: var(--blue-500); + } + + &.selected { + background-color: var(--blue-500); + border-color: var(--blue-500); + color: white; + } + + &:focus { + outline: none; + box-shadow: 0 0 0 2px var(--blue-200); + } +} + +.radioOptions { + display: flex; + flex-direction: column; + gap: 12px; + + label { + display: flex; + align-items: center; + gap: 12px; + color: var(--paragraph-color); + font-size: 14px; + cursor: pointer; + + input[type="radio"] { + appearance: none; + width: 16px; + height: 16px; + border: 2px solid var(--border-color); + border-radius: 50%; + margin: 0; + cursor: pointer; + transition: all 0.2s ease; + + &:checked { + border-color: var(--blue-500); + background-color: var(--blue-500); + box-shadow: inset 0 0 0 3px var(--foreground-color); + } + + &:hover:not(:checked) { + border-color: var(--blue-400); + } + } + } +} + +.attendanceSection { + margin-top: 32px; + padding-top: 32px; + border-top: 1px solid var(--border-color); +} + +@keyframes overlayShow { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +@keyframes contentShow { + from { + opacity: 0; + transform: translate(-50%, -48%) scale(0.96); + } + to { + opacity: 1; + transform: translate(-50%, -50%) scale(1); + } +} + +.closeButton { + position: absolute; + right: 24px; + top: 24px; + width: 24px; + height: 24px; + display: flex; + align-items: center; + justify-content: center; + border: none; + background: none; + color: var(--label-color); + cursor: pointer; + font-size: 18px; + padding: 0; + transition: color 0.2s ease; + + &:hover { + color: var(--heading-color); + } + + &:focus { + outline: none; + color: var(--heading-color); + } +} diff --git a/apps/frontend/src/components/UserFeedbackModal/UserFeedbackModal.tsx b/apps/frontend/src/components/UserFeedbackModal/UserFeedbackModal.tsx new file mode 100644 index 000000000..0f03dcfcb --- /dev/null +++ b/apps/frontend/src/components/UserFeedbackModal/UserFeedbackModal.tsx @@ -0,0 +1,67 @@ +import React from 'react'; +import * as Dialog from "@radix-ui/react-dialog"; +import { Button } from "@repo/theme"; +import { RatingsForm } from './RatingForm'; +import { AttendanceForm } from './AttendanceForm'; +import { ClassData } from './types'; +import styles from './UserFeedbackModal.module.scss'; + +interface UserFeedbackModalProps { + isOpen: boolean; + onClose: () => void; + title: string; + subtitle?: string; + currentClass: ClassData; +} + +export function UserFeedbackModal({ + isOpen, + onClose, + title, + subtitle, + currentClass +}: UserFeedbackModalProps) { + return ( + + + + + + ✕ + +
+ + {title} + +
+ + {currentClass.subject} {currentClass.courseNumber} + + +
+
+ +
+
+ + +
+
+ +
+ + + + +
+
+
+
+ ); +} diff --git a/apps/frontend/src/components/UserFeedbackModal/index.ts b/apps/frontend/src/components/UserFeedbackModal/index.ts new file mode 100644 index 000000000..f99cc2735 --- /dev/null +++ b/apps/frontend/src/components/UserFeedbackModal/index.ts @@ -0,0 +1,2 @@ +export { UserFeedbackModal } from './UserFeedbackModal'; +export type { ClassData } from './types'; \ No newline at end of file diff --git a/apps/frontend/src/components/UserFeedbackModal/types.ts b/apps/frontend/src/components/UserFeedbackModal/types.ts new file mode 100644 index 000000000..80a8701b2 --- /dev/null +++ b/apps/frontend/src/components/UserFeedbackModal/types.ts @@ -0,0 +1,6 @@ +export interface ClassData { + subject: string; + courseNumber: string; + semester: string; + year: string; +} diff --git a/apps/frontend/src/lib/api/classes.ts b/apps/frontend/src/lib/api/classes.ts index 38f036e59..c6b4b7e96 100644 --- a/apps/frontend/src/lib/api/classes.ts +++ b/apps/frontend/src/lib/api/classes.ts @@ -165,6 +165,8 @@ export interface ISection { startDate: string; endDate: string; exams: IExam[]; + attendanceRequired: boolean; + lecturesRecorded: boolean; } export interface IReservation {