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
47 changes: 47 additions & 0 deletions client/src/api/review/review.api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { apiProtected, apiPublic } from "../api";

import {
ReviewType,
PaginatedReviewsResponse,
TeacherAverageRating,
CreateReviewInput,
} from "./review.type";

// Get all reviews - PUBLIC -
// BE endpoint: GET /api/reviews?teacherId=xxx&pageNumber=1&pageSize=5
export async function getReviewsApi(
teacherId: string,
pageNumber: number = 1,
pageSize: number = 5,
): Promise<PaginatedReviewsResponse> {
const res = await apiPublic.get<PaginatedReviewsResponse>(
`/api/reviews/teachers/${teacherId}`,
{
params: {
pageNumber,
pageSize,
},
},
);
return res.data;
}

// Get average rating for a teacher - PUBLIC -
// BE endpoint: GET /api/reviews/:teacherId/average-rating
export async function getTeacherAverageRatingApi(
teacherId: string,
): Promise<TeacherAverageRating> {
const res = await apiPublic.get<TeacherAverageRating>(
`/api/reviews/${teacherId}/average-rating`,
);
return res.data;
}

// Create a review - PROTECTED -
// BE endpoint: POST /api/reviews
export async function createReviewApi(
data: CreateReviewInput,
): Promise<ReviewType> {
const res = await apiProtected.post<ReviewType>("/api/reviews", data);
return res.data;
}
35 changes: 35 additions & 0 deletions client/src/api/review/review.type.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
export type ReviewType = {
_id: string;
bookingId: string;
teacherId: string;
studentId: string;
studentName: string;
studentAvatar?: string;
rating: number;
subject?: string;
review?: string;
createdAt: string;
updatedAt: string;
};

export type PaginatedReviewsResponse = {
pageCount: number;
page: number;
pageSize: number;
totalCount: number;
reviews: ReviewType[];
};

export type TeacherAverageRating = {
teacherId: string;
averageRating: number;
totalReviews: number;
};

export type CreateReviewInput = {
bookingId: string;
rating: number;
review?: string;
subject?: string;
teacherId: string;
};
5 changes: 5 additions & 0 deletions client/src/components/rating/Rating.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,11 @@ type RatingType = {
rating: number;
};

// what does thuis file do?
// This component takes a rating value (between 0 and 5) as a prop and renders a visual representation of that rating using star icons.
// It displays filled stars for the rating value and empty stars for the remaining out of 5. For example, if the rating is 3, it will show 3 filled stars and 2 empty stars.
//

export const Rating = ({ rating }: RatingType) => {
return (
<div className="flex gap-[4px]">
Expand Down
184 changes: 184 additions & 0 deletions client/src/components/teacherSection/Reviews/AddReview.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
import React, { useState } from "react";
import { Button } from "../../ui/button/Button";
import { useCreateReviewMutation } from "../../../features/review/mutations/useCreateReviewMutation";
import { useStudentAppointmentsQuery } from "../../../features/appointments/query/useAppointmentsQuery";
import { useAuthSessionStore } from "../../../store/authSession.store";
import { Appointment } from "../../../types/appointments.types";
import { Loader } from "../../loader/Loader";
import { useNotificationStore } from "../../../store/notification.store";
import { ReviewType } from "../../../api/review/review.type";

interface AddReviewFormProps {
teacherId: string;
accumulatedReviews?: ReviewType[];
}

export const AddReview = ({
teacherId,
accumulatedReviews = [],
}: AddReviewFormProps) => {
const { user } = useAuthSessionStore();
const isLoggedIn = !!user;
const studentId = user?.id || "";
const notifyError = useNotificationStore((s) => s.error);

const [rating, setRating] = useState(0);
const [reviewText, setReviewText] = useState("");
const [selectedBookingId, setSelectedBookingId] = useState("");

const { data, isLoading } = useStudentAppointmentsQuery(studentId);
const { mutate, isPending } = useCreateReviewMutation(teacherId);

// Get the set of bookingIds that already have reviews
const reviewedBookingIds = new Set(
accumulatedReviews.map((r) => r.bookingId),
);

// Filter to only approved lessons with this teacher, without lessonse already reviewed
const approvedLessons =
data?.appointments.filter(
(app) =>
app.teacherId === teacherId &&
app.status === "approved" &&
!reviewedBookingIds.has(app.id),
) || [];
console.log("All appointments for student:", data?.appointments[0]);
console.log("Approved lessons for review:", approvedLessons);
console.log("Check:", {
allReviews: accumulatedReviews.map((r) => r.bookingId),
thisBookingId: "699b29c90bfad36c573ba5ff",
});

console.log("Full Review Object:", accumulatedReviews[0]);

// If not logged in, don't show the review form
if (!isLoggedIn) return null;
if (isLoading)
return (
<div className="flex justify-center p-4">
<Loader />
</div>
);

// Find the selected booking details for use in the review
const selectBooking = approvedLessons?.find(
(app: Appointment) => app.id === selectedBookingId,
);

const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (rating === 0) {
return notifyError("Please provide a rating for the lesson");
}
if (!selectedBookingId)
return notifyError("Please select a lesson to review");

mutate(
{
teacherId,
bookingId: selectedBookingId,
rating,
review: reviewText,
subject: selectBooking?.lesson || "",
},
{
onSuccess: () => {
setRating(0);
setReviewText("");
setSelectedBookingId("");
},
onError: (error) => {
const msg =
error instanceof Error
? error.message
: "Failed to submit review. Please try again.";
notifyError(msg);
},
},
);
};
return (
<div className="bg-[#1A1926] mb-12 p-8 border border-white/5 rounded-2xl">
<h3 className="mb-6 font-bold text-white text-2xl">Review Your Lesson</h3>

<form onSubmit={handleSubmit} className="space-y-6">
<div>
<label
htmlFor="lesson-select"
className="block mb-2 text-white/60 text-sm"
>
Select a lesson
</label>
<select
id="lesson-select"
className="bg-dark px-4 py-3 border border-white/10 focus:border-primary-500 rounded-lg outline-none w-full text-white cursor-pointer"
value={selectedBookingId}
onChange={(e) => setSelectedBookingId(e.target.value)}
required
>
<option value="" className="bg-[#1A1926] text-white">
-- Choose a lesson --
</option>

{approvedLessons?.map((app: Appointment) => (
<option
key={app.id}
value={app.id}
className="bg-purple-500/80 text-white cursor-pointer"
>
{`${app.lesson} ---- ${new Date(app.date).toLocaleDateString(
"en-GB",
{
day: "numeric",
month: "short",
year: "numeric",
},
)}
`}
</option>
))}
</select>
</div>

{/* Rating Stars */}
<div>
<label className="block mb-2 text-white/60 text-sm">
How was the lesson?
</label>
<div className="flex gap-4">
{[1, 2, 3, 4, 5].map((star) => (
<button
key={star}
type="button"
onClick={() => setRating(star)}
className={`text-3xl transition cursor-pointer ${star <= rating ? "text-yellow-400" : "text-gray-600 "}`}
>
</button>
))}
</div>
</div>

<div>
<label className="block mb-2 text-white/60 text-sm">
Review - (Optional)
</label>
<textarea
placeholder="What did you learn? How was the teaching style?"
className="bg-dark px-4 py-3 border border-white/10 rounded-lg w-full h-32 text-white resize-none"
value={reviewText}
onChange={(e) => setReviewText(e.target.value)}
/>
</div>

<Button
variant="primary"
disabled={isPending || !selectedBookingId}
className="disabled:bg-gray-600 py-4 w-full disabled:cursor-not-allowed"
>
{isPending ? "Submitting..." : "Submit Review"}
</Button>
</form>
</div>
);
};
42 changes: 19 additions & 23 deletions client/src/components/teacherSection/Reviews/ReviewCardTeacher.tsx
Original file line number Diff line number Diff line change
@@ -1,35 +1,31 @@
import ReviewsIcon from "../../icons/Reviews";
import { Rating } from "../../rating/Rating";
import UsersIcon from "../../icons/UsersIcon";
import { ReviewType } from "../../../api/review/review.type";

//TODO : change the props schema to match the real data structure of reviews when we have it from the backend.
//
interface ReviewCardProps {
name: string;
avatar?: string;
rating: number;
course: string;
review?: string;
createdAt?: string;
reviewData: ReviewType;
}

export const ReviewCardTeacher = ({
name,
avatar,
rating,
course,
review,
createdAt,
reviewData: {
studentName,
studentAvatar,
rating,
subject,
review,
createdAt,
},
}: ReviewCardProps) => {
return (
<div className="w-full">
<div className="flex flex-col bg-[#15141D] p-[16px] md:p-[25px] rounded-2xl h-auto">
<div className="w-full h-full">
<div className="flex flex-col bg-[#15141D] p-[16px] md:p-[25px] rounded-2xl h-full">
{/* Header Part (Avatar , Name, Icon ) */}
<div className="flex items-center gap-[10px] md:gap-[16px] mb-[12px] md:mb-[20px]">
{avatar ? (
{studentAvatar ? (
<img
src={avatar}
alt={name}
src={studentAvatar}
alt={studentName}
className="flex-shrink-0 rounded-full w-[28px] md:w-[40px] h-[28px] md:h-[40px] object-cover"
/>
) : (
Expand All @@ -38,10 +34,10 @@ export const ReviewCardTeacher = ({

<div className="flex-1 min-w-0">
<h4 className="font-semibold text-white text-xs md:text-base truncate">
{name}
{studentName}
</h4>
<p className="text-[10px] text-white/60 md:text-sm truncate">
{course}
{subject}
</p>
</div>

Expand All @@ -54,13 +50,13 @@ export const ReviewCardTeacher = ({
<Rating rating={rating} />
{createdAt && (
<span className="text-[10px] text-white/40 md:text-xs">
{new Date(createdAt).toLocaleDateString()}
{new Date(createdAt).toLocaleDateString("en-GB")}
</span>
)}
</div>

{review && (
<p className="text-white/80 text-xs md:text-base leading-relaxed">
<p className="text-white/80 text-xs md:text-base wrap-break-word">
{review}
</p>
)}
Expand Down
Loading
Loading