Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 7 additions & 14 deletions src/app/components/courses/CourseReviews.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
'use client';

import { useState } from 'react';
import { useRatingStore } from '@/app/store/ratingStore';

interface Review {
id: string;
Expand Down Expand Up @@ -54,16 +54,7 @@ export default function CourseReviews({
},
],
}: CourseReviewsProps) {
const [helpful, setHelpful] = useState<Record<string, number>>(
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 (
Expand Down Expand Up @@ -162,8 +153,10 @@ export default function CourseReviews({
{review.comment}
</p>
<button
onClick={() => markHelpful(review.id)}
className="inline-flex items-center gap-2 text-sm text-[#64748B] transition-colors hover:text-[#0066FF] dark:text-[#94A3B8] dark:hover:text-[#00C2FF]"
onClick={() => markHelpful(review.id, review.helpful)}
disabled={hasVotedHelpful(review.id)}
aria-pressed={hasVotedHelpful(review.id)}
className="inline-flex items-center gap-2 text-sm text-[#64748B] transition-colors hover:text-[#0066FF] dark:text-[#94A3B8] dark:hover:text-[#00C2FF] disabled:cursor-not-allowed disabled:opacity-60"
>
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
Expand All @@ -173,7 +166,7 @@ export default function CourseReviews({
d="M14 10h4.764a2 2 0 011.789 2.894l-3.5 7A2 2 0 0115.263 21h-4.017c-.163 0-.326-.02-.485-.06L7 20m7-10V5a2 2 0 00-2-2h-.095c-.5 0-.905.405-.905.905 0 .714-.211 1.412-.608 2.006L7 11v9m7-10h-2M7 20H5a2 2 0 01-2-2v-6a2 2 0 012-2h2.5"
/>
</svg>
Helpful ({helpful[review.id]})
Helpful ({getHelpfulCount(review.id, review.helpful)})
</button>
</div>
</div>
Expand Down
108 changes: 108 additions & 0 deletions src/app/store/__tests__/ratingStore.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { useRatingStore } from '../ratingStore';

// ─── Mock localStorage ────────────────────────────────────────────────────────

const localStorageMock = (() => {
let store: Record<string, string> = {};
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);
});
});
60 changes: 60 additions & 0 deletions src/app/store/ratingStore.ts
Original file line number Diff line number Diff line change
@@ -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<string, number>;
/** Map of reviewId → whether current user has already voted helpful. */
votedHelpful: Record<string, boolean>;
/** Map of reviewId → user-submitted star rating (1–5). */
userRatings: Record<string, number>;
}

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<RatingState & RatingActions>()(
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' },
),
);
28 changes: 13 additions & 15 deletions src/components/courses/CourseReviews.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -58,29 +59,24 @@ export default function CourseReviews({
},
],
}: CourseReviewsProps) {
const [helpful, setHelpful] = useState<Record<string, number>>(
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<ReviewSortKey>('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 }[] = [
Expand Down Expand Up @@ -272,8 +268,10 @@ export default function CourseReviews({
)}
</p>
<button
onClick={() => markHelpful(review.id)}
className="inline-flex items-center gap-2 text-sm text-[#64748B] dark:text-[#94A3B8] hover:text-[#0066FF] dark:hover:text-[#00C2FF] transition-colors"
onClick={() => markHelpful(review.id, review.helpful)}
disabled={hasVotedHelpful(review.id)}
aria-pressed={hasVotedHelpful(review.id)}
className="inline-flex items-center gap-2 text-sm text-[#64748B] dark:text-[#94A3B8] hover:text-[#0066FF] dark:hover:text-[#00C2FF] transition-colors disabled:opacity-60 disabled:cursor-not-allowed"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
Expand All @@ -283,7 +281,7 @@ export default function CourseReviews({
d="M14 10h4.764a2 2 0 011.789 2.894l-3.5 7A2 2 0 0115.263 21h-4.017c-.163 0-.326-.02-.485-.06L7 20m7-10V5a2 2 0 00-2-2h-.095c-.5 0-.905.405-.905.905 0 .714-.211 1.412-.608 2.006L7 11v9m7-10h-2M7 20H5a2 2 0 01-2-2v-6a2 2 0 012-2h2.5"
/>
</svg>
Helpful ({helpful[review.id]})
Helpful ({getHelpfulCount(review.id, review.helpful)})
</button>
</div>
</div>
Expand Down
Loading