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({
)}