diff --git a/apps/backend/src/modules/rating/controller.ts b/apps/backend/src/modules/rating/controller.ts index 32b53f238..55709aeac 100644 --- a/apps/backend/src/modules/rating/controller.ts +++ b/apps/backend/src/modules/rating/controller.ts @@ -8,6 +8,7 @@ import { CourseModel, RatingModel, RatingType, + ReviewModel, SectionModel, } from "@repo/common/models"; import { METRIC_MAPPINGS, REQUIRED_METRICS } from "@repo/shared"; @@ -493,7 +494,50 @@ export const getUserRatings = async (context: RequestContext) => { count: 0, classes: [], }; - return formatUserRatings(userRatings[0]); + const formattedUserRatings = formatUserRatings(userRatings[0]); + const activeReviews = await ReviewModel.find({ + createdBy: context.user._id, + $or: [{ valid: true }, { valid: { $exists: false } }], + }) + .select( + "subject courseNumber reviewTitle reviewContent reviewerGrade updatedAt" + ) + .sort({ updatedAt: -1 }) + .lean(); + + const reviewByCourse = new Map< + string, + { + reviewTitle?: string | null; + reviewContent?: string | null; + reviewerGrade?: string | null; + } + >(); + activeReviews.forEach((review) => { + const key = `${review.subject}|${review.courseNumber}`; + if (!reviewByCourse.has(key)) { + reviewByCourse.set(key, { + reviewTitle: review.reviewTitle ?? null, + reviewContent: review.reviewContent ?? null, + reviewerGrade: (review.reviewerGrade as string | null) ?? "n/a", + }); + } + }); + + return { + ...formattedUserRatings, + classes: formattedUserRatings.classes.map((ratedClass) => { + const review = reviewByCourse.get( + `${ratedClass.subject}|${ratedClass.courseNumber}` + ); + return { + ...ratedClass, + reviewTitle: review?.reviewTitle ?? null, + reviewContent: review?.reviewContent ?? null, + reviewerGrade: review?.reviewerGrade ?? "n/a", + }; + }), + }; }; const filterAggregatedMetrics = ( @@ -934,7 +978,10 @@ export const createRatings = async ( subject: string, courseNumber: string, classNumber: string, - metrics: MetricInput[] + metrics: MetricInput[], + reviewTitle?: string | null, + reviewContent?: string | null, + reviewerGrade?: string | null ) => { if (!context.user._id) { throw new GraphQLError("Unauthorized", { @@ -1011,7 +1058,77 @@ export const createRatings = async ( ]); } - // Step 2: Create all new ratings and increment their aggregated counts + // Step 2: Soft-delete and create review snapshots + // Update or create the review for this user/course. + if ( + reviewTitle !== undefined || + reviewContent !== undefined || + reviewerGrade !== undefined + ) { + const normalizedTitle = (reviewTitle ?? "").trim(); + const normalizedContent = (reviewContent ?? "").trim(); + const normalizedReviewerGrade = + (reviewerGrade ?? "n/a").trim() || "n/a"; + const hasReviewPayload = + normalizedTitle.length > 0 || normalizedContent.length > 0; + + const existingReview = await ReviewModel.findOne({ + createdBy: context.user._id, + courseId, + $or: [{ valid: true }, { valid: { $exists: false } }], + }).session(session); + + if (!hasReviewPayload) { + if (existingReview) { + await ReviewModel.updateOne( + { _id: existingReview._id }, + { $set: { valid: false } }, + { session } + ); + } + } else if (existingReview) { + await ReviewModel.updateOne( + { _id: existingReview._id }, + { + $set: { + reviewTitle: normalizedTitle, + reviewContent: normalizedContent, + reviewerGrade: normalizedReviewerGrade, + classId, + subject, + courseNumber, + semester, + year, + classNumber, + valid: true, + }, + }, + { session } + ); + } else { + await ReviewModel.create( + [ + { + createdBy: context.user._id, + courseId, + reviewTitle: normalizedTitle, + reviewContent: normalizedContent, + reviewerGrade: normalizedReviewerGrade, + classId, + subject, + courseNumber, + semester, + year, + classNumber, + valid: true, + }, + ], + { session } + ); + } + } + + // Step 3: Create all new ratings and increment their aggregated counts for (const metric of metrics) { await Promise.all([ RatingModel.create( @@ -1085,6 +1202,15 @@ export const deleteRatings = async ( const session = await connection.startSession(); try { await session.withTransaction(async () => { + // Delete reviews for all affected courseIds + await ReviewModel.deleteMany( + { + createdBy: context.user._id, + courseId: { $in: Array.from(affectedCourseIds) }, + }, + { session } + ); + // Delete all ratings and decrement their aggregated counts for (const existingRating of existingRatings) { await Promise.all([ @@ -1119,6 +1245,42 @@ const anonymizeUserId = (userId: string): string => { return createHash("sha256").update(userId).digest("hex").slice(0, 16); }; +export const voteReviewHelpful = async ( + context: RequestContext, + reviewId: string +): Promise => { + if (!context.user._id) { + throw new GraphQLError("Unauthorized", { + extensions: { code: "UNAUTHENTICATED" }, + }); + } + + const review = await ReviewModel.findById(reviewId); + if (!review) { + throw new GraphQLError("Review not found", { + extensions: { code: "NOT_FOUND" }, + }); + } + + const userId = context.user._id; + const alreadyVoted = + (review.helpfulVoters as string[] | undefined)?.includes(userId) ?? false; + + if (alreadyVoted) { + await ReviewModel.updateOne( + { _id: review._id }, + { $pull: { helpfulVoters: userId }, $inc: { helpfulCount: -1 } } + ); + return Math.max(0, ((review.helpfulCount as number | undefined) ?? 0) - 1); + } else { + await ReviewModel.updateOne( + { _id: review._id }, + { $addToSet: { helpfulVoters: userId }, $inc: { helpfulCount: 1 } } + ); + return ((review.helpfulCount as number | undefined) ?? 0) + 1; + } +}; + export const getAllRatings = async () => { const ratings = await RatingModel.find({}).lean(); const shadowBannedCourseIds = await getShadowBannedCourseIds(); @@ -1155,3 +1317,161 @@ export const getAllRatings = async () => { : new Date().toISOString(), })); }; + +export const getClassRatings = async ( + subject: string, + courseNumber: string +) => { + const ratings = await RatingModel.find({ subject, courseNumber }).lean(); + const sections = await SectionModel.find({ subject, courseNumber }) + .select("semester year classNumber number meetings") + .lean(); + const shadowBannedCourseIds = await getShadowBannedCourseIds(); + const crossListedShadowBanEnabled = isShadowBanCrossListedEnabled(); + + const visibleRatings = ratings.filter((rating) => { + if (isSubjectShadowBanned(rating.subject)) { + return false; + } + + if ( + crossListedShadowBanEnabled && + typeof rating.courseId === "string" && + shadowBannedCourseIds.has(rating.courseId) + ) { + return false; + } + + return true; + }); + const activeReviews = await ReviewModel.find({ + subject, + courseNumber, + $or: [{ valid: true }, { valid: { $exists: false } }], + }) + .sort({ updatedAt: -1 }) + .lean(); + + const reviewByUser = new Map< + string, + { + reviewId: string; + reviewTitle?: string | null; + reviewContent?: string | null; + reviewerGrade?: string | null; + helpfulCount: number; + } + >(); + activeReviews.forEach((review) => { + if (reviewByUser.has(review.createdBy)) return; + reviewByUser.set(review.createdBy, { + reviewId: review._id?.toString() ?? "", + reviewTitle: review.reviewTitle ?? null, + reviewContent: review.reviewContent ?? null, + reviewerGrade: (review.reviewerGrade as string | null) ?? "n/a", + helpfulCount: (review.helpfulCount as number | undefined) ?? 0, + }); + }); + + const instructorNamesByClassKey = new Map>(); + sections.forEach((section) => { + const classNumber = section.classNumber ?? section.number; + if (!classNumber) return; + const classKey = `${section.semester}|${section.year}|${classNumber}`; + + const names = instructorNamesByClassKey.get(classKey) ?? new Set(); + section.meetings?.forEach((meeting) => { + meeting.instructors?.forEach((instructor) => { + if ( + instructor.role === "PI" && + instructor.givenName && + instructor.familyName + ) { + names.add(`${instructor.givenName} ${instructor.familyName}`); + } + }); + }); + instructorNamesByClassKey.set(classKey, names); + }); + + type UserClassEntry = { + subject: string; + courseNumber: string; + semester: Semester; + year: number; + classNumber: string; + professorName?: string | null; + metrics: { metricName: MetricName; value: number }[]; + reviewTitle?: string | null; + reviewContent?: string | null; + reviewerGrade?: string | null; + lastUpdated: string; + reviewId?: string | null; + helpfulCount: number; + }; + + const userClassesById = new Map>(); + visibleRatings.forEach((rating) => { + const userId = rating.createdBy; + const classKey = `${rating.semester}|${rating.year}|${rating.classNumber}`; + const userClasses = userClassesById.get(userId) ?? new Map(); + const existingClass = userClasses.get(classKey); + const timestamp = + "updatedAt" in rating && rating.updatedAt instanceof Date + ? rating.updatedAt + : "createdAt" in rating && rating.createdAt instanceof Date + ? rating.createdAt + : new Date(0); + + if (!existingClass) { + const review = reviewByUser.get(userId); + userClasses.set(classKey, { + subject: rating.subject, + courseNumber: rating.courseNumber, + semester: rating.semester as Semester, + year: rating.year, + classNumber: rating.classNumber, + professorName: + Array.from(instructorNamesByClassKey.get(classKey) ?? []).join( + ", " + ) || null, + metrics: [ + { + metricName: rating.metricName as MetricName, + value: rating.value, + }, + ], + reviewTitle: review?.reviewTitle ?? null, + reviewContent: review?.reviewContent ?? null, + reviewerGrade: review?.reviewerGrade ?? "n/a", + lastUpdated: timestamp.toISOString(), + reviewId: review?.reviewId ?? null, + helpfulCount: review?.helpfulCount ?? 0, + }); + userClassesById.set(userId, userClasses); + return; + } + + existingClass.metrics.push({ + metricName: rating.metricName as MetricName, + value: rating.value, + }); + if (timestamp.toISOString() > existingClass.lastUpdated) { + existingClass.lastUpdated = timestamp.toISOString(); + } + }); + + const users = Array.from(userClassesById.entries()).map( + ([userId, classes]) => ({ + anonymousUserId: anonymizeUserId(userId), + classes: Array.from(classes.values()), + }) + ); + + return { + subject, + courseNumber, + count: users.length, + users, + }; +}; diff --git a/apps/backend/src/modules/rating/formatter.ts b/apps/backend/src/modules/rating/formatter.ts index cb6271d95..b148c82db 100644 --- a/apps/backend/src/modules/rating/formatter.ts +++ b/apps/backend/src/modules/rating/formatter.ts @@ -34,6 +34,10 @@ export const formatUserRatings = (ratings: UserRatings): UserRatings => { value: userMetric.value, })), + reviewTitle: (userClass as UserClass & { reviewTitle?: string | null }) + .reviewTitle, + reviewContent: (userClass as UserClass & { reviewContent?: string | null }) + .reviewContent, lastUpdated: userClass.lastUpdated?.toString(), })), }; @@ -91,6 +95,10 @@ export const formatUserClassRatings = (ratings: UserClass): UserClass => { metricName: metric.metricName as MetricName, value: metric.value, })), + reviewTitle: (ratings as UserClass & { reviewTitle?: string | null }) + .reviewTitle, + reviewContent: (ratings as UserClass & { reviewContent?: string | null }) + .reviewContent, lastUpdated: ratings.lastUpdated, }; }; diff --git a/apps/backend/src/modules/rating/resolver.ts b/apps/backend/src/modules/rating/resolver.ts index 9c437acf0..0045f356b 100644 --- a/apps/backend/src/modules/rating/resolver.ts +++ b/apps/backend/src/modules/rating/resolver.ts @@ -5,9 +5,11 @@ import { deleteRatings, getAllRatings, getClassAggregatedRatings, + getClassRatings, getSemestersWithRatings, getUserClassRatings, getUserRatings, + voteReviewHelpful, } from "./controller"; import { RatingModule } from "./generated-types/module-types"; @@ -137,14 +139,47 @@ const resolvers: RatingModule.Resolvers = { ); } }, + + classReviews: async ( + _: unknown, + { subject, courseNumber }: { subject: string; courseNumber: string } + ) => { + try { + return await getClassRatings(subject, courseNumber); + } catch (error: unknown) { + if (error instanceof GraphQLError) { + throw error; + } + throw new GraphQLError( + typeof error === "object" && error !== null && "message" in error + ? String(error.message) + : "An unexpected error occurred", + { + extensions: { code: "INTERNAL_SERVER_ERROR" }, + } + ); + } + }, }, Mutation: { - createRatings: async ( - _, - { year, semester, subject, courseNumber, classNumber, metrics }, - context - ) => { + createRatings: async (_, args, context) => { try { + const { + year, + semester, + subject, + courseNumber, + classNumber, + metrics, + reviewTitle, + reviewContent, + reviewerGrade, + } = args as typeof args & { + reviewTitle?: string | null; + reviewContent?: string | null; + reviewerGrade?: string | null; + }; + return await createRatings( context, Number(year), @@ -152,7 +187,10 @@ const resolvers: RatingModule.Resolvers = { subject, courseNumber, classNumber, - metrics + metrics, + reviewTitle, + reviewContent, + reviewerGrade ); } catch (error: unknown) { // Re-throw GraphQLErrors as is @@ -171,6 +209,24 @@ const resolvers: RatingModule.Resolvers = { } }, + voteReviewHelpful: async (_, { reviewId }, context) => { + try { + return await voteReviewHelpful(context, reviewId); + } catch (error: unknown) { + if (error instanceof GraphQLError) { + throw error; + } + throw new GraphQLError( + typeof error === "object" && error !== null && "message" in error + ? String(error.message) + : "An unexpected error occurred", + { + extensions: { code: "INTERNAL_SERVER_ERROR" }, + } + ); + } + }, + deleteRatings: async (_, { subject, courseNumber }, context) => { try { return await deleteRatings(context, subject, courseNumber); diff --git a/apps/frontend/src/app/Profile/Ratings/index.tsx b/apps/frontend/src/app/Profile/Ratings/index.tsx index 42c6fcbff..5730792a0 100644 --- a/apps/frontend/src/app/Profile/Ratings/index.tsx +++ b/apps/frontend/src/app/Profile/Ratings/index.tsx @@ -194,7 +194,14 @@ export default function Ratings() { async ( metricValues: MetricData, termInfo: { semester: Semester; year: number }, - courseInfo: { subject: string; courseNumber: string; classNumber: string } + courseInfo: { + subject: string; + courseNumber: string; + classNumber: string; + }, + reviewTitle?: string, + reviewContent?: string, + reviewerGrade?: string ) => { if (!ratingForEdit) return; @@ -217,6 +224,9 @@ export default function Ratings() { number: courseInfo.classNumber, }, refetchQueries: buildRefetchQueries(refetchTarget), + reviewTitle, + reviewContent, + reviewerGrade, }); }, [ratingForEdit, createRatingsMutation, buildRefetchQueries] @@ -226,7 +236,14 @@ export default function Ratings() { async ( metricValues: MetricData, termInfo: { semester: Semester; year: number }, - courseInfo: { subject: string; courseNumber: string; classNumber: string } + courseInfo: { + subject: string; + courseNumber: string; + classNumber: string; + }, + reviewTitle?: string, + reviewContent?: string, + reviewerGrade?: string ) => { const refetchTarget = { subject: courseInfo.subject, @@ -246,6 +263,9 @@ export default function Ratings() { number: courseInfo.classNumber, }, refetchQueries: buildRefetchQueries(refetchTarget), + reviewTitle, + reviewContent, + reviewerGrade, }); }, [createRatingsMutation, buildRefetchQueries] @@ -336,8 +356,22 @@ export default function Ratings() { : null } availableTerms={availableTerms} - onSubmit={async (metricValues, termInfo, courseInfo) => { - await handleSubmitEdit(metricValues, termInfo, courseInfo); + onSubmit={async ( + metricValues, + termInfo, + courseInfo, + reviewTitle, + reviewContent, + reviewerGrade + ) => { + await handleSubmitEdit( + metricValues, + termInfo, + courseInfo, + reviewTitle, + reviewContent, + reviewerGrade + ); }} initialUserClass={ratingForEdit} onSubmitPopupChange={setIsEditThankYouOpen} diff --git a/apps/frontend/src/components/Class/Ratings/ClassRatingSummary/ClassRatingSummary.module.scss b/apps/frontend/src/components/Class/Ratings/ClassRatingSummary/ClassRatingSummary.module.scss new file mode 100644 index 000000000..45f72c850 --- /dev/null +++ b/apps/frontend/src/components/Class/Ratings/ClassRatingSummary/ClassRatingSummary.module.scss @@ -0,0 +1,264 @@ +.root { + background-color: var(--foreground-color); + border: 1px solid var(--border-color); + border-radius: 8px; + display: flex; + align-items: top; + // padding: 0px 24px; + margin-bottom: 4px; + min-width: 450px; + + .body { + display: flex; + padding: var(--space-4, 16px) var(--space-5, 24px); + flex-direction: column; + align-items: flex-start; + gap: var(--space-6, 32px); + align-self: stretch; + } + + .body:first-child { + flex: 1; + } + + .body + .body { + margin-left: auto; + align-self: flex-start; + } + .bodyLeft { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: var(--space-4, 16px); + align-self: stretch; + + .titleDate { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 4px; + flex: 1 0 0; + } + h3, + h4, + h5 { + margin: 0; + } + h3 { + color: var(--secondary-color); + font-feature-settings: "liga" off; + font-family: Inter; + font-size: var(--font-sizes-2, 14px); + font-style: normal; + font-weight: 600; + line-height: 20px; + letter-spacing: -0.1px; + word-break: break-word; + overflow-wrap: break-word; + } + h4 { + color: var(--paragraph-color); + font-feature-settings: "liga" off; + font-family: Inter; + font-size: var(--font-sizes-1, 12px); + font-style: normal; + font-weight: var(--font-weight-regular, 400); + line-height: 16px; + } + h5 { + color: var(--secondary-color); + font-feature-settings: "liga" off; + font-family: Inter; + font-size: var(--font-sizes-2, 14px); + font-style: normal; + font-weight: 400; + line-height: 20px; + letter-spacing: -0.1px; + word-break: break-word; + overflow-wrap: break-word; + } + + .contentWrapper { + position: relative; + color: var(--secondary-color); + font-feature-settings: "liga" off; + font-family: Inter; + font-size: var(--font-sizes-2, 14px); + font-weight: 400; + line-height: 20px; + letter-spacing: -0.1px; + word-break: break-word; + overflow-wrap: break-word; + align-self: stretch; + + &.clamped { + max-height: 80px; // 4 lines × 20px + overflow: hidden; + } + + .moreButtonInline { + display: inline; + background: none; + border: none; + padding: 0 0 0 4px; + color: var(--blue-badge); + font-family: Inter; + font-size: var(--font-sizes-2, 14px); + line-height: 20px; + cursor: pointer; + } + + .moreButton { + position: absolute; + bottom: 0; + right: 0; + background: var(--foreground-color); + border: none; + padding: 0 0 0 6px; + color: var(--blue-badge); + font-family: Inter; + font-size: var(--font-sizes-2, 14px); + line-height: 20px; + cursor: pointer; + } + } + + .actions { + display: flex; + gap: var(--space-3, 12px); + } + + .helpfulButton { + display: flex; + height: 32px; + padding: var(--space-1, 4px) var(--space-3, 12px); + justify-content: center; + align-items: center; + gap: var(--space-2, 8px); + background: none; + border: 1px solid var(--border-color); + border-radius: 4px; + font-family: Inter; + font-size: var(--font-sizes-2, 14px); + font-weight: 400; + color: var(--secondary-color); + cursor: pointer; + + &:hover { + background-color: var(--button-hover-color); + } + + &.helpfulButtonActive { + background-color: var(--blue-500-20); + color: var(--blue-badge); + } + } + + .reportButton { + background: none; + border: none; + padding: 0; + font-family: Inter; + font-size: var(--font-sizes-2, 14px); + font-weight: 400; + color: var(--paragraph-color); + cursor: pointer; + + &:hover { + color: var(--secondary-color); + } + } + + .metricsRow { + display: flex; + align-items: flex-start; + gap: var(--space-4, 16px); + align-self: stretch; + margin-top: var(--space-2, 8px); + + .metricItem { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 4px; + flex: 1 0 0; + padding-right: var(--space-4, 16px); + + &:not(:last-child) { + border-right: 1px solid var(--border-color); + } + } + + .metricLabel { + color: var(--secondary-color); + font-family: Inter; + font-size: var(--font-sizes-2, 14px); + font-weight: 500; + line-height: 20px; + letter-spacing: -0.1px; + } + + .metricValue { + color: var(--paragraph-color); + font-family: Inter; + font-size: var(--font-sizes-2, 14px); + font-weight: 400; + line-height: 20px; + } + } + } + .bodyRight { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: var(--space-1, 4px); + align-self: flex-start; + + > * { + margin: 0; + } + + > :nth-child(3) { + margin-top: var(--space-5, 24px); + } + + .rating { + display: flex; + padding: 2px var(--space-2, 8px); + justify-content: center; + align-items: center; + align-self: center; + text-align: center; + border-radius: 4px; + background: var(--green-500-20); + color: var(--green-badge); + } + + .grade { + display: flex; + min-width: 22px; + min-height: 22px; + padding: 0 5px; + justify-content: center; + align-items: center; + align-self: center; + text-align: center; + color: var(--green-badge); + + &.naGrade { + color: var(--paragraph-color); + } + } + } +} + +.ratingGrade { + color: var(--secondary-color); + font-feature-settings: "liga" off; + font-family: Inter; + font-size: var(--font-sizes-2, 14px); + font-style: normal; + font-weight: 500; + line-height: 20px; + letter-spacing: -0.1px; +} diff --git a/apps/frontend/src/components/Class/Ratings/ClassRatingSummary/index.tsx b/apps/frontend/src/components/Class/Ratings/ClassRatingSummary/index.tsx new file mode 100644 index 000000000..4b729fd56 --- /dev/null +++ b/apps/frontend/src/components/Class/Ratings/ClassRatingSummary/index.tsx @@ -0,0 +1,184 @@ +import { useLayoutEffect, useRef, useState } from "react"; + +import { useMutation } from "@apollo/client/react"; + +import { METRIC_ORDER, MetricName } from "@repo/shared"; + +import { VOTE_REVIEW_HELPFUL } from "@/lib/api/ratings"; +import { + VoteReviewHelpfulMutation, + VoteReviewHelpfulMutationVariables, +} from "@/lib/generated/graphql"; + +import { + formatDate, + getAverageRatingColor, + isMetricRating, +} from "../metricsUtil"; +// eslint-disable-next-line css-modules/no-unused-class +import styles from "./ClassRatingSummary.module.scss"; + +export interface ClassUserReview { + professorName?: string | null; + metrics?: Array<{ metricName: MetricName; value: number }>; + reviewTitle?: string | null; + reviewContent?: string | null; + reviewerGrade?: string | null; + lastUpdated?: string | null; + reviewId?: string | null; + helpfulCount?: number | null; +} + +export default function ClassRatingSummary({ + classReview, +}: { + classReview: ClassUserReview; +}) { + const [voteHelpful] = useMutation< + VoteReviewHelpfulMutation, + VoteReviewHelpfulMutationVariables + >(VOTE_REVIEW_HELPFUL); + + const storageKey = classReview.reviewId + ? `bt_helpful_${classReview.reviewId}` + : null; + + const [hasVoted, setHasVoted] = useState(() => + storageKey ? localStorage.getItem(storageKey) === "true" : false + ); + + const handleHelpful = async () => { + if (!classReview.reviewId || !storageKey) return; + const next = !hasVoted; + setHasVoted(next); + localStorage.setItem(storageKey, String(next)); + await voteHelpful({ variables: { reviewId: classReview.reviewId } }); + }; + + const ratingMetrics = (classReview.metrics ?? []).filter((metric) => + isMetricRating(MetricName[metric.metricName]) + ); + const metricsAverage = + ratingMetrics.length > 0 + ? ratingMetrics.reduce((sum, m) => { + const value = + m.metricName === MetricName.Difficulty || + m.metricName === MetricName.Workload + ? 5 - m.value + : m.value; + return sum + value; + }, 0) / ratingMetrics.length + : null; + + const rawGrade = classReview.reviewerGrade; + const displayGrade = + rawGrade && rawGrade.toLowerCase() !== "n/a" ? rawGrade : "N/A"; + + const ratingColor = + metricsAverage != null ? getAverageRatingColor(metricsAverage) : null; + + const [isExpanded, setIsExpanded] = useState(false); + const [isOverflowing, setIsOverflowing] = useState(false); + const contentRef = useRef(null); + + useLayoutEffect(() => { + const el = contentRef.current; + if (!el) return; + setIsOverflowing(el.scrollHeight > el.clientHeight); + }, [classReview.reviewContent]); + + return ( +
+
+
+
+

{classReview.reviewTitle || "No title"}

+ {classReview.lastUpdated && ( +

{formatDate(new Date(classReview.lastUpdated))}

+ )} +
+
+ {classReview.reviewContent || "No written review yet."} + {!isExpanded && isOverflowing && ( + + )} + {!isExpanded && !isOverflowing && ( + + )} +
+ {isExpanded && ( +
+ {METRIC_ORDER.map((metricName) => { + const metric = (classReview.metrics ?? []).find( + (m) => m.metricName === metricName + ); + if (!metric) return null; + return ( +
+ {metricName} + {metric.value} +
+ ); + })} +
+ )} +
+ + {/* */} +
+
+
+
+
+

Rating

+
+ {metricsAverage != null ? ( + {metricsAverage.toFixed(1)} + ) : ( + N/A + )} +
+

Grade

+
+ {displayGrade} +
+
+
+
+ ); +} diff --git a/apps/frontend/src/components/Class/Ratings/RatingButton/RatingButton.module.scss b/apps/frontend/src/components/Class/Ratings/RatingButton/RatingButton.module.scss index 1fc1e9241..eb4b1eb35 100644 --- a/apps/frontend/src/components/Class/Ratings/RatingButton/RatingButton.module.scss +++ b/apps/frontend/src/components/Class/Ratings/RatingButton/RatingButton.module.scss @@ -1,16 +1,19 @@ .button { - color: var(--blue-500) !important; - background-color: var(--foreground-color); - height: 38px; + display: flex; + height: 32px; + padding: var(--space-1, 4px) var(--space-3, 12px); + align-items: center; + gap: var(--space-2, 8px); + color: white !important; + background-color: var(--blue-500) !important; + + &:hover { + background-color: var(--blue-hover) !important; + } &.invalid { - background: var(--background-color); + background: var(--background-color) !important; cursor: default; color: var(--paragraph-color) !important; - - &:hover { - background: var(--background-color); - color: var(--paragraph-color) !important; - } } } diff --git a/apps/frontend/src/components/Class/Ratings/RatingDetail/RatingDetail.module.scss b/apps/frontend/src/components/Class/Ratings/RatingDetail/RatingDetail.module.scss index ac80f16a6..b3046f300 100644 --- a/apps/frontend/src/components/Class/Ratings/RatingDetail/RatingDetail.module.scss +++ b/apps/frontend/src/components/Class/Ratings/RatingDetail/RatingDetail.module.scss @@ -5,11 +5,15 @@ display: flex; justify-content: space-between; align-items: center; + min-height: 36px; + padding: 4px 0; cursor: pointer; .titleAndStatusSection { display: flex; align-items: center; + gap: 8px; + min-height: 0; .titleSection { display: flex; @@ -18,17 +22,20 @@ width: 140px; .title { - color: var(--paragraph-color); - font-size: var(--text-16); + margin: 0; + color: var(--neutral-700); + font-size: var(--text-14); font-weight: var(--font-normal); + line-height: 1.25; } } .metricAverage { color: var(--label-color); - padding-left: 14px; - font-size: var(--text-14); + padding-left: 8px; + font-size: var(--text-12); + line-height: 1.25; } } .statusSection { diff --git a/apps/frontend/src/components/Class/Ratings/Ratings.module.scss b/apps/frontend/src/components/Class/Ratings/Ratings.module.scss index b3454e619..5574e70b1 100644 --- a/apps/frontend/src/components/Class/Ratings/Ratings.module.scss +++ b/apps/frontend/src/components/Class/Ratings/Ratings.module.scss @@ -1,16 +1,46 @@ .root { - background-color: var(--background-color); - padding: 20px; - height: 100%; + display: flex; + flex-direction: column; + align-self: stretch; } -.header { - margin-bottom: 16px; +.ratingsBox { + padding: 20px 24px; +} + +.containerContents { + display: flex; + flex-direction: column; + gap: var(--space-5, 24px); + align-self: stretch; + min-width: 0; +} + +.ratingsHeaderToolbar { + display: flex; + align-items: center; + gap: 12px; + text-align: left; } .termSelectWrapper { - margin-left: auto; - width: 250px; + width: 300px; + flex-shrink: 0; + > div[tabindex="0"] { + box-sizing: border-box; + width: 100%; + min-height: 0; + height: 32px; + padding: 0 8px; + justify-content: flex-start; + align-items: center; + gap: 8px; + + > div:last-of-type > div:last-child { + border-left: none !important; + padding-left: 0 !important; + } + } } .ratingsSelectContent { @@ -67,20 +97,93 @@ border-radius: 8px; padding: 0 24px; border: 1px solid var(--border-color); - min-width: 450px; + width: 100%; + min-width: 0; +} + +.ratingsSummary { + display: flex; + flex-direction: column; + align-items: stretch; + gap: var(--space-3, 12px); + align-self: stretch; +} + +.ratingsSummaryTop { + display: flex; + height: 32px; + align-items: center; + gap: 12px; + align-self: stretch; +} + +.ratingsSummaryBottom { + display: flex; + align-items: center; + gap: var(--space-5, 24px); + align-self: stretch; +} + +.ratingsSummaryBottomRight { + display: flex; + flex-direction: column; + align-items: stretch; + flex: 1 0 0; + min-width: 0; +} + +.ratingsSummaryHeader { + color: var(--secondary-color); + font-feature-settings: "liga" off; + + /* Web UI (Desktop)/Heading/Heading Medium */ + font-family: Inter; + font-size: var(--font-sizes-5, 20px); + font-style: normal; + font-weight: 600; + line-height: 24px; /* 120% */ + letter-spacing: -0.2px; +} + +.ratingsSummaryHeaderRight { + margin-left: auto; + text-align: right; +} + +.ratingsSummaryCount { + color: var(--secondary-color); + font-feature-settings: "liga" off; + + /* Web UI (Desktop)/Title/Title Small */ + font-family: Inter; + font-size: var(--font-sizes-2, 14px); + font-style: normal; + font-weight: 500; + line-height: 16px; /* 133.333% */ +} + +.noWrittenReviews { + display: flex; + padding: 80px var(--space-4, 16px); + justify-content: center; + align-items: center; +} + +.noWrittenReviewsText { + color: var(--paragraph-color); + font-family: Inter; + font-size: var(--font-sizes-2, 14px); + font-weight: 400; + line-height: 20px; + text-align: center; + margin: 0; } .ratingSection { - padding: 24px 0; + padding: 10px 0; border-bottom: 1px solid var(--border-color); &:last-child { border-bottom: none; } } - -.userOnlyMessage { - margin-top: 12px; - margin-bottom: 0; - color: var(--label-color); -} diff --git a/apps/frontend/src/components/Class/Ratings/UserFeedbackModal/FeedbackForm.tsx b/apps/frontend/src/components/Class/Ratings/UserFeedbackModal/FeedbackForm.tsx index 1e5696e50..10e097528 100644 --- a/apps/frontend/src/components/Class/Ratings/UserFeedbackModal/FeedbackForm.tsx +++ b/apps/frontend/src/components/Class/Ratings/UserFeedbackModal/FeedbackForm.tsx @@ -37,7 +37,6 @@ interface RatingScaleProps { export function AttendanceForm({ metricData, setMetricData, - startQuestionNumber, }: AttendanceFormProps) { const handleAttendanceClickClick = ( type: MetricName, @@ -61,19 +60,22 @@ export function AttendanceForm({ ]; return ( -
- {ATTENDANCE_QUESTIONS.map(({ type, question }, index) => ( -
-
-

- {startQuestionNumber + index}. {question} -

- handleAttendanceClickClick(type, v)} - /> -
+
+ {ATTENDANCE_QUESTIONS.map(({ type, question }) => ( +
+

{question}

+ handleAttendanceClickClick(type, v)} + />
))}
@@ -114,7 +116,7 @@ export function RatingsForm({ ]; return ( -
+
{RATING_QUESTIONS.map( ({ type, question, leftLabel, rightLabel }, index) => ( void; + showRequiredAsterisk?: boolean; +} + +interface ReviewContentFormProps { + reviewContent: string; + setReviewContent: (value: string) => void; + showRequiredAsterisk?: boolean; +} + +export function ReviewTitleForm({ + reviewTitle, + setReviewTitle, + showRequiredAsterisk = false, +}: ReviewTitleFormProps) { + return ( +
+
+

+ Title of your review? {showRequiredAsterisk && } +

+ setReviewTitle(e.target.value)} + placeholder="Title" + maxLength={100} + aria-label="Review title" + /> +
+
+ ); +} + +export function ReviewContentForm({ + reviewContent, + setReviewContent, + showRequiredAsterisk = false, +}: ReviewContentFormProps) { + return ( +
+
+

Write a Review {showRequiredAsterisk && }

+