diff --git a/src/app/components/courses/CourseReviews.tsx b/src/app/components/courses/CourseReviews.tsx index 29d7670f..97b8d8b8 100644 --- a/src/app/components/courses/CourseReviews.tsx +++ b/src/app/components/courses/CourseReviews.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useState } from 'react'; +import { useRatingStore } from '@/app/store/ratingStore'; interface Review { id: string; @@ -54,16 +54,7 @@ export default function CourseReviews({ }, ], }: CourseReviewsProps) { - const [helpful, setHelpful] = useState>( - reviews.reduce((acc, review) => ({ ...acc, [review.id]: review.helpful }), {}), - ); - - const markHelpful = (reviewId: string) => { - setHelpful((prev) => ({ - ...prev, - [reviewId]: (prev[reviewId] || 0) + 1, - })); - }; + const { markHelpful, getHelpfulCount, hasVotedHelpful } = useRatingStore(); const renderStars = (rating: number) => { return ( @@ -162,8 +153,10 @@ export default function CourseReviews({ {review.comment}

diff --git a/src/app/store/__tests__/ratingStore.test.ts b/src/app/store/__tests__/ratingStore.test.ts new file mode 100644 index 00000000..30d25ad7 --- /dev/null +++ b/src/app/store/__tests__/ratingStore.test.ts @@ -0,0 +1,108 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { useRatingStore } from '../ratingStore'; + +// ─── Mock localStorage ──────────────────────────────────────────────────────── + +const localStorageMock = (() => { + let store: Record = {}; + return { + getItem: (key: string) => store[key] ?? null, + setItem: (key: string, value: string) => { store[key] = value; }, + removeItem: (key: string) => { delete store[key]; }, + clear: () => { store = {}; }, + }; +})(); + +vi.stubGlobal('localStorage', localStorageMock); + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +function resetStore() { + localStorageMock.clear(); + useRatingStore.setState({ helpfulVotes: {}, votedHelpful: {}, userRatings: {} }); +} + +// ─── Tests ──────────────────────────────────────────────────────────────────── + +describe('ratingStore', () => { + beforeEach(resetStore); + + // ── markHelpful ──────────────────────────────────────────────────────────── + + it('increments helpful count on first vote', () => { + const { markHelpful, getHelpfulCount } = useRatingStore.getState(); + markHelpful('r1', 10); + expect(getHelpfulCount('r1', 10)).toBe(11); + }); + + it('does not double-increment on repeat vote', () => { + const { markHelpful, getHelpfulCount } = useRatingStore.getState(); + markHelpful('r1', 10); + markHelpful('r1', 10); + expect(getHelpfulCount('r1', 10)).toBe(11); + }); + + it('returns seed count when no vote has been cast', () => { + const { getHelpfulCount } = useRatingStore.getState(); + expect(getHelpfulCount('r2', 5)).toBe(5); + }); + + it('tracks voted state correctly', () => { + const { markHelpful, hasVotedHelpful } = useRatingStore.getState(); + expect(hasVotedHelpful('r1')).toBe(false); + markHelpful('r1', 3); + expect(hasVotedHelpful('r1')).toBe(true); + }); + + it('marks only the voted review as voted', () => { + const { markHelpful, hasVotedHelpful } = useRatingStore.getState(); + markHelpful('r1', 3); + expect(hasVotedHelpful('r2')).toBe(false); + }); + + // ── setUserRating ────────────────────────────────────────────────────────── + + it('stores a valid user rating', () => { + const { setUserRating, getUserRating } = useRatingStore.getState(); + setUserRating('r1', 4); + expect(getUserRating('r1')).toBe(4); + }); + + it('overwrites a previous user rating', () => { + const { setUserRating, getUserRating } = useRatingStore.getState(); + setUserRating('r1', 3); + setUserRating('r1', 5); + expect(getUserRating('r1')).toBe(5); + }); + + it('ignores ratings outside 1–5 range', () => { + const { setUserRating, getUserRating } = useRatingStore.getState(); + setUserRating('r1', 0); + setUserRating('r1', 6); + expect(getUserRating('r1')).toBeUndefined(); + }); + + it('returns undefined when no rating is set', () => { + expect(useRatingStore.getState().getUserRating('r99')).toBeUndefined(); + }); + + // ── persistence ─────────────────────────────────────────────────────────── + + it('retains votes across a store state reset (simulates re-hydration)', () => { + const { markHelpful } = useRatingStore.getState(); + markHelpful('r1', 7); + // Simulate re-hydration by merging stored state back + const stored = { helpfulVotes: { r1: 8 }, votedHelpful: { r1: true }, userRatings: {} }; + useRatingStore.setState(stored); + expect(useRatingStore.getState().getHelpfulCount('r1', 7)).toBe(8); + expect(useRatingStore.getState().hasVotedHelpful('r1')).toBe(true); + }); + + it('retains user ratings across a store state reset (simulates re-hydration)', () => { + const { setUserRating } = useRatingStore.getState(); + setUserRating('r1', 5); + const stored = { helpfulVotes: {}, votedHelpful: {}, userRatings: { r1: 5 } }; + useRatingStore.setState(stored); + expect(useRatingStore.getState().getUserRating('r1')).toBe(5); + }); +}); diff --git a/src/app/store/ratingStore.ts b/src/app/store/ratingStore.ts new file mode 100644 index 00000000..7c4c90f0 --- /dev/null +++ b/src/app/store/ratingStore.ts @@ -0,0 +1,60 @@ +import { create } from 'zustand'; +import { persist } from 'zustand/middleware'; + +interface RatingState { + /** Map of reviewId → current helpful vote count delta (added on top of seed). */ + helpfulVotes: Record; + /** Map of reviewId → whether current user has already voted helpful. */ + votedHelpful: Record; + /** Map of reviewId → user-submitted star rating (1–5). */ + userRatings: Record; +} + +interface RatingActions { + markHelpful: (reviewId: string, seedCount: number) => void; + setUserRating: (reviewId: string, rating: number) => void; + getHelpfulCount: (reviewId: string, seedCount: number) => number; + hasVotedHelpful: (reviewId: string) => boolean; + getUserRating: (reviewId: string) => number | undefined; +} + +export const useRatingStore = create()( + persist( + (set, get) => ({ + helpfulVotes: {}, + votedHelpful: {}, + userRatings: {}, + + markHelpful(reviewId, seedCount) { + if (get().votedHelpful[reviewId]) return; + set((state) => ({ + helpfulVotes: { + ...state.helpfulVotes, + [reviewId]: seedCount + 1, + }, + votedHelpful: { ...state.votedHelpful, [reviewId]: true }, + })); + }, + + setUserRating(reviewId, rating) { + if (rating < 1 || rating > 5) return; + set((state) => ({ + userRatings: { ...state.userRatings, [reviewId]: rating }, + })); + }, + + getHelpfulCount(reviewId, seedCount) { + return get().helpfulVotes[reviewId] ?? seedCount; + }, + + hasVotedHelpful(reviewId) { + return get().votedHelpful[reviewId] ?? false; + }, + + getUserRating(reviewId) { + return get().userRatings[reviewId]; + }, + }), + { name: 'teachlink-ratings' }, + ), +); diff --git a/src/components/courses/CourseReviews.tsx b/src/components/courses/CourseReviews.tsx index 6def85b0..e5ba3e8e 100644 --- a/src/components/courses/CourseReviews.tsx +++ b/src/components/courses/CourseReviews.tsx @@ -2,6 +2,7 @@ import { useMemo, useState } from 'react'; import { searchReviews, highlightTerms, type ReviewSortKey } from '@/utils/reviewSearch'; +import { useRatingStore } from '@/app/store/ratingStore'; interface Review { id: string; @@ -58,29 +59,24 @@ export default function CourseReviews({ }, ], }: CourseReviewsProps) { - const [helpful, setHelpful] = useState>( - reviews.reduce((acc, review) => ({ ...acc, [review.id]: review.helpful }), {}), - ); + const { markHelpful, getHelpfulCount, hasVotedHelpful } = useRatingStore(); const [query, setQuery] = useState(''); const [minRating, setMinRating] = useState(0); const [sortBy, setSortBy] = useState('relevance'); - const markHelpful = (reviewId: string) => { - setHelpful((prev) => ({ - ...prev, - [reviewId]: (prev[reviewId] || 0) + 1, - })); - }; - // Run the search engine over the reviews whenever a control changes. Helpful // counts are kept live so "most helpful" sorting reflects the latest votes. const filteredReviews = useMemo( () => searchReviews( - reviews.map((review) => ({ ...review, helpful: helpful[review.id] ?? review.helpful })), + reviews.map((review) => ({ + ...review, + helpful: getHelpfulCount(review.id, review.helpful), + })), { query, minRating, sortBy }, ).map((scored) => scored.review), - [reviews, helpful, query, minRating, sortBy], + // eslint-disable-next-line react-hooks/exhaustive-deps + [reviews, query, minRating, sortBy], ); const sortOptions: { value: ReviewSortKey; label: string }[] = [ @@ -272,8 +268,10 @@ export default function CourseReviews({ )}