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.reviewerAvatar ? ( + {review.reviewerName} + ) : ( +
+ +
+ )} +
+
+

+ {review.reviewerName} +

+ {review.verified && ( + + Verified + + )} +
+
+ + {formatDate(review.createdAt)} +
+
+
+ +
+ +
+
+ + {review.comment && ( +

+ {review.comment} +

+ )} + +
+
+ Contract #{review.contractId} +
+
+
+ ); +} \ 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 && ( +
+
+
+ Unverified +
+ + {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