diff --git a/app/dashboard/projects/[id]/page.tsx b/app/dashboard/projects/[id]/page.tsx
index c3513bf..96d46f8 100644
--- a/app/dashboard/projects/[id]/page.tsx
+++ b/app/dashboard/projects/[id]/page.tsx
@@ -16,6 +16,9 @@ import {
EscrowStatusTracker,
type EscrowStage,
} from "@/components/dashboard/escrow-status-tracker";
+import { ReviewsList } from "@/components/ui/reviews-list";
+import { AverageScoreDisplay } from "@/components/ui/average-score-display";
+import { ReviewsSummary } from "@/components/ui/reviews-summary";
interface Milestone {
id: string;
@@ -419,6 +422,45 @@ export default function ProjectDetailPage() {
+ {/* Ratings & Reviews Summary */}
+ {project.freelancer && (
+
+
+
+
+
+
+
+ Recent Reviews
+
+
+
+
+ )}
+
{/* Tabs */}
diff --git a/components/ui/average-score-display.tsx b/components/ui/average-score-display.tsx
new file mode 100644
index 0000000..0968bbd
--- /dev/null
+++ b/components/ui/average-score-display.tsx
@@ -0,0 +1,99 @@
+"use client";
+
+import { Star } from "lucide-react";
+import { cn } from "@/lib/utils";
+import { ReviewCard } from "@/components/ui/review-card";
+
+export interface Review {
+ id: number;
+ contractId: number;
+ reviewerId: number;
+ reviewerName: string;
+ reviewerAvatar?: string;
+ freelancerId: number;
+ freelancerName: string;
+ rating: number;
+ comment?: string;
+ verified: boolean;
+ createdAt: string;
+}
+
+export interface AverageScoreDisplayProps {
+ reviews: Review[];
+ className?: string;
+ showCount?: boolean;
+ size?: "sm" | "md" | "lg";
+}
+
+export function AverageScoreDisplay({
+ reviews,
+ className,
+ showCount = true,
+ size = "md",
+}: AverageScoreDisplayProps) {
+ const sizeConfig = {
+ sm: {
+ container: "px-3 py-1.5 rounded-lg",
+ star: "h-3.5 w-3.5",
+ text: "text-sm",
+ value: "text-base",
+ },
+ md: {
+ container: "px-4 py-2 rounded-xl",
+ star: "h-5 w-5",
+ text: "text-base",
+ value: "text-lg",
+ },
+ lg: {
+ container: "px-5 py-3 rounded-2xl",
+ star: "h-6 w-6",
+ text: "text-lg",
+ value: "text-xl",
+ },
+ };
+
+ const config = sizeConfig[size];
+
+ if (reviews.length === 0) {
+ return (
+
+ No reviews available
+
+ );
+ }
+
+ const averageRating = reviews.reduce((sum, review) => sum + review.rating, 0) / reviews.length;
+ const maxRating = 5;
+
+ const getRatingColor = (rating: number) => {
+ if (rating >= 4.5) return "text-emerald-400";
+ if (rating >= 4.0) return "text-green-400";
+ if (rating >= 3.5) return "text-lime-400";
+ if (rating >= 3.0) return "text-yellow-400";
+ if (rating >= 2.0) return "text-orange-400";
+ return "text-destructive";
+ };
+
+ return (
+
+
+
+
+ {averageRating.toFixed(1)}
+
+
+
+ {showCount && (
+
+ ({reviews.length} review{reviews.length !== 1 ? "s" : ""})
+
+ )}
+
+ );
+}
\ No newline at end of file
diff --git a/components/ui/review-card.tsx b/components/ui/review-card.tsx
new file mode 100644
index 0000000..337c137
--- /dev/null
+++ b/components/ui/review-card.tsx
@@ -0,0 +1,118 @@
+"use client";
+
+import { Star, ShieldCheck, User, Calendar } from "lucide-react";
+import { cn } from "@/lib/utils";
+
+export interface Review {
+ id: number;
+ contractId: number;
+ reviewerId: number;
+ reviewerName: string;
+ reviewerAvatar?: string;
+ freelancerId: number;
+ freelancerName: string;
+ rating: number;
+ comment?: string;
+ verified: boolean;
+ createdAt: string;
+}
+
+function RatingStars({ rating, size = "sm", interactive = false }: {
+ rating: number;
+ size?: "sm" | "md" | "lg";
+ interactive?: boolean;
+}) {
+ const sizeConfig: Record = {
+ sm: "h-4 w-4",
+ md: "h-5 w-5",
+ lg: "h-6 w-6",
+ };
+ const stars = [];
+
+ for (let i = 0; i < 5; i++) {
+ const filled = interactive ? i < rating : i < Math.floor(rating);
+ stars.push(
+
+ );
+ }
+
+ return (
+
+ {stars}
+ {!interactive && rating > 0 && (
+
+ {rating.toFixed(1)}
+
+ )}
+
+ );
+}
+
+export function ReviewCard({ review }: { review: Review }) {
+ const formatDate = (dateStr: string) => {
+ const date = new Date(dateStr);
+ return date.toLocaleDateString("en-US", {
+ month: "short",
+ day: "numeric",
+ year: "numeric",
+ });
+ };
+
+ return (
+
+
+
+ {review.comment && (
+
+ {review.comment}
+
+ )}
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/components/ui/reviews-list.tsx b/components/ui/reviews-list.tsx
new file mode 100644
index 0000000..1638ebc
--- /dev/null
+++ b/components/ui/reviews-list.tsx
@@ -0,0 +1,147 @@
+"use client";
+
+import { useState, useEffect } from "react";
+import { Loader2 } from "lucide-react";
+import { cn } from "@/lib/utils";
+import { ReviewCard } from "@/components/ui/review-card";
+
+interface ReviewListResponse {
+ data: {
+ id: number;
+ contract_id: number;
+ reviewer_id: number;
+ reviewer_name: string;
+ reviewer_avatar?: string;
+ freelancer_id: number;
+ freelancer_name: string;
+ rating: number;
+ comment?: string;
+ verified: boolean;
+ created_at: string;
+ }[];
+ meta: {
+ totalCount: number;
+ page: number;
+ limit: number;
+ totalPages: number;
+ };
+}
+
+function LoadingReviews() {
+ return (
+
+
+
+ );
+}
+
+function EmptyReviews() {
+ return (
+
+
+
+
+
No reviews yet
+
Be the first to leave a review!
+
+ );
+}
+
+export interface ReviewsListProps {
+ freelancerId: number;
+ initialData?: ReviewListResponse;
+ className?: string;
+}
+
+export function ReviewsList({ freelancerId, initialData, className }: ReviewsListProps) {
+ const [data, setData] = useState(initialData);
+ const [loading, setLoading] = useState(!initialData);
+ const [page, setPage] = useState(initialData?.meta.page || 1);
+ const [hasMore, setHasMore] = useState(
+ initialData ? initialData.meta.page < initialData.meta.totalPages : true
+ );
+
+ const loadMore = async () => {
+ if (loading || !hasMore) return;
+
+ setLoading(true);
+ try {
+ const response = await fetch(
+ `/api/reviews/${freelancerId}?page=${page + 1}&limit=10`
+ );
+
+ if (!response.ok) {
+ throw new Error("Failed to load more reviews");
+ }
+
+ const newData = (await response.json()) as ReviewListResponse;
+
+ if (newData.data.length > 0) {
+ setData((prev) => {
+ if (!prev) return newData;
+
+ return {
+ data: [...prev.data, ...newData.data],
+ meta: newData.meta,
+ };
+ });
+
+ setPage((prev) => prev + 1);
+ setHasMore(page + 1 < newData.meta.totalPages);
+ } else {
+ setHasMore(false);
+ }
+ } catch (error) {
+ console.error("Error loading more reviews:", error);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const reviews: any[] = data?.data || [];
+ const hasReviews = reviews.length > 0;
+
+ return (
+
+ {loading && page === 1 ? (
+
+ ) : !hasReviews ? (
+
+ ) : (
+
+ {reviews.map((review) => (
+
+ ))}
+
+ {hasMore && (
+
+
+
+ )}
+
+ )}
+
+ );
+}
\ No newline at end of file
diff --git a/components/ui/reviews-summary.tsx b/components/ui/reviews-summary.tsx
new file mode 100644
index 0000000..782395f
--- /dev/null
+++ b/components/ui/reviews-summary.tsx
@@ -0,0 +1,84 @@
+"use client";
+
+import { Clock, CheckCircle2, Star } from "lucide-react";
+import { cn } from "@/lib/utils";
+
+export interface AverageScoreDisplayProps {
+ reviewCount: number;
+ averageRating: number;
+ lastUpdated?: string;
+ verifiedCount?: number;
+ className?: string;
+}
+
+export function ReviewsSummary({ reviewCount, averageRating, lastUpdated, verifiedCount, className }: AverageScoreDisplayProps) {
+ const formatDate = (dateStr: string) => {
+ const date = new Date(dateStr);
+ return date.toLocaleDateString("en-US", {
+ month: "short",
+ day: "numeric",
+ year: "numeric",
+ });
+ };
+
+ const ratingColor = (rating: number) => {
+ if (rating >= 4.5) return "text-emerald-400";
+ if (rating >= 4.0) return "text-green-400";
+ if (rating >= 3.5) return "text-lime-400";
+ if (rating >= 3.0) return "text-yellow-400";
+ if (rating >= 2.0) return "text-orange-400";
+ return "text-destructive";
+ };
+
+ return (
+
+
+
Reviews Summary
+ {lastUpdated && (
+
+
+ Updated {formatDate(lastUpdated)}
+
+ )}
+
+
+
+
+
+
+ Average Rating
+
+
.
+ {averageRating.toFixed(1)}
+
+
+
+
+
+
+
+
+
+ Verified Reviews
+
+
+ {verifiedCount || 0} of {reviewCount}
+
+
+
+ {verifiedCount !== undefined && verifiedCount < reviewCount && (
+
+
+
+ {reviewCount - (verifiedCount || 0)}
+
+
+ )}
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/components/ui/star-rating.tsx b/components/ui/star-rating.tsx
new file mode 100644
index 0000000..9a4a91d
--- /dev/null
+++ b/components/ui/star-rating.tsx
@@ -0,0 +1,113 @@
+"use client";
+
+import { CheckCircle2, Star } from "lucide-react";
+import { cn } from "@/lib/utils";
+
+export interface VerifiedReviewBadgeProps {
+ verified: boolean;
+ size?: "sm" | "md" | "lg";
+ className?: string;
+}
+
+export function VerifiedReviewBadge({ verified, size = "md", className }: VerifiedReviewBadgeProps) {
+ if (!verified) return null;
+
+ const sizeConfig = {
+ sm: {
+ container: "px-2 py-0.5 rounded-full",
+ icon: "h-3 w-3",
+ text: "text-xs",
+ },
+ md: {
+ container: "px-2.5 py-1 rounded-full",
+ icon: "h-3.5 w-3.5",
+ text: "text-xs",
+ },
+ lg: {
+ container: "px-3 py-1.5 rounded-full",
+ icon: "h-4 w-4",
+ text: "text-sm",
+ },
+ };
+
+ const config = sizeConfig[size];
+
+ return (
+
+
+ Verified
+
+ );
+}
+
+export interface StarRatingProps {
+ rating: number;
+ size?: "sm" | "md" | "lg";
+ showValue?: boolean;
+ interactive?: boolean;
+ onChange?: (rating: number) => void;
+ className?: string;
+}
+
+export function StarRating({
+ rating,
+ size = "md",
+ showValue = false,
+ interactive = false,
+ onChange,
+ className,
+}: StarRatingProps) {
+ const sizeConfig = {
+ sm: "h-4 w-4",
+ md: "h-5 w-5",
+ lg: "h-6 w-6",
+ };
+
+ return (
+
+ {Array.from({ length: 5 }, (_, index) => {
+ const isFilled = interactive ? index < rating : index < Math.floor(rating);
+ const isHalfFilled = interactive ? false : index + 0.5 === rating;
+
+ return (
+
+ );
+ })}
+
+ {showValue && (
+
+ {rating.toFixed(1)}
+
+ )}
+
+ );
+}
\ No newline at end of file