Skip to content

Commit 68c1a1c

Browse files
feat: implement review system UI with star ratings, pagination, verified badges, and empty state
1 parent c336a6e commit 68c1a1c

6 files changed

Lines changed: 603 additions & 0 deletions

File tree

app/dashboard/projects/[id]/page.tsx

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,9 @@ import {
1616
EscrowStatusTracker,
1717
type EscrowStage,
1818
} from "@/components/dashboard/escrow-status-tracker";
19+
import { ReviewsList } from "@/components/ui/reviews-list";
20+
import { AverageScoreDisplay } from "@/components/ui/average-score-display";
21+
import { ReviewsSummary } from "@/components/ui/reviews-summary";
1922

2023
interface Milestone {
2124
id: string;
@@ -419,6 +422,45 @@ export default function ProjectDetailPage() {
419422
</Card>
420423
</div>
421424

425+
{/* Ratings & Reviews Summary */}
426+
{project.freelancer && (
427+
<div className="space-y-6">
428+
<AverageScoreDisplay
429+
reviews={[{
430+
id: 0,
431+
contractId: 0,
432+
reviewerId: 0,
433+
reviewerName: "Client",
434+
freelancerId: parseInt(project.freelancer?.username?.replace('id-', '') || '0', 10),
435+
freelancerName: project.freelancer.display_name || project.freelancer.username || "",
436+
rating: project.freelancer.avg_rating || 0,
437+
comment: "",
438+
verified: true,
439+
createdAt: new Date().toISOString(),
440+
}]}
441+
className="w-full"
442+
showCount={true}
443+
size="md"
444+
/>
445+
446+
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
447+
<ReviewsSummary
448+
reviewCount={project.freelancer.total_reviews || 0}
449+
averageRating={project.freelancer.avg_rating || 0}
450+
verifiedCount={project.freelancer.total_reviews || 0}
451+
/>
452+
453+
<Card className="p-5 bg-card/50 border-border/40 backdrop-blur-sm rounded-xl">
454+
<h3 className="text-lg font-semibold text-foreground mb-4">Recent Reviews</h3>
455+
<ReviewsList
456+
freelancerId={parseInt(project.freelancer?.username?.replace('id-', '') || '0', 10)}
457+
className="space-y-4"
458+
/>
459+
</Card>
460+
</div>
461+
</div>
462+
)}
463+
422464
{/* Tabs */}
423465
<Tabs defaultValue="milestones" className="w-full">
424466
<TabsList className="grid w-full grid-cols-2 bg-card/50 border-border/40 p-1">
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
"use client";
2+
3+
import { Star } from "lucide-react";
4+
import { cn } from "@/lib/utils";
5+
import { ReviewCard } from "@/components/ui/review-card";
6+
7+
export interface Review {
8+
id: number;
9+
contractId: number;
10+
reviewerId: number;
11+
reviewerName: string;
12+
reviewerAvatar?: string;
13+
freelancerId: number;
14+
freelancerName: string;
15+
rating: number;
16+
comment?: string;
17+
verified: boolean;
18+
createdAt: string;
19+
}
20+
21+
export interface AverageScoreDisplayProps {
22+
reviews: Review[];
23+
className?: string;
24+
showCount?: boolean;
25+
size?: "sm" | "md" | "lg";
26+
}
27+
28+
export function AverageScoreDisplay({
29+
reviews,
30+
className,
31+
showCount = true,
32+
size = "md",
33+
}: AverageScoreDisplayProps) {
34+
const sizeConfig = {
35+
sm: {
36+
container: "px-3 py-1.5 rounded-lg",
37+
star: "h-3.5 w-3.5",
38+
text: "text-sm",
39+
value: "text-base",
40+
},
41+
md: {
42+
container: "px-4 py-2 rounded-xl",
43+
star: "h-5 w-5",
44+
text: "text-base",
45+
value: "text-lg",
46+
},
47+
lg: {
48+
container: "px-5 py-3 rounded-2xl",
49+
star: "h-6 w-6",
50+
text: "text-lg",
51+
value: "text-xl",
52+
},
53+
};
54+
55+
const config = sizeConfig[size];
56+
57+
if (reviews.length === 0) {
58+
return (
59+
<div className={cn("text-center text-muted-foreground", config.text)}>
60+
No reviews available
61+
</div>
62+
);
63+
}
64+
65+
const averageRating = reviews.reduce((sum, review) => sum + review.rating, 0) / reviews.length;
66+
const maxRating = 5;
67+
68+
const getRatingColor = (rating: number) => {
69+
if (rating >= 4.5) return "text-emerald-400";
70+
if (rating >= 4.0) return "text-green-400";
71+
if (rating >= 3.5) return "text-lime-400";
72+
if (rating >= 3.0) return "text-yellow-400";
73+
if (rating >= 2.0) return "text-orange-400";
74+
return "text-destructive";
75+
};
76+
77+
return (
78+
<div
79+
className={cn(
80+
"inline-flex items-center gap-2 bg-card/50 border border-border/60 rounded-xl",
81+
config.container,
82+
className
83+
)}
84+
>
85+
<div className="flex items-center gap-1">
86+
<Star className={cn("fill-current", config.star, getRatingColor(averageRating))} />
87+
<span className={cn("font-bold", config.value, getRatingColor(averageRating))}>
88+
{averageRating.toFixed(1)}
89+
</span>
90+
</div>
91+
92+
{showCount && (
93+
<span className={cn("text-muted-foreground", config.text)}>
94+
({reviews.length} review{reviews.length !== 1 ? "s" : ""})
95+
</span>
96+
)}
97+
</div>
98+
);
99+
}

components/ui/review-card.tsx

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
"use client";
2+
3+
import { Star, ShieldCheck, User, Calendar } from "lucide-react";
4+
import { cn } from "@/lib/utils";
5+
6+
export interface Review {
7+
id: number;
8+
contractId: number;
9+
reviewerId: number;
10+
reviewerName: string;
11+
reviewerAvatar?: string;
12+
freelancerId: number;
13+
freelancerName: string;
14+
rating: number;
15+
comment?: string;
16+
verified: boolean;
17+
createdAt: string;
18+
}
19+
20+
function RatingStars({ rating, size = "sm", interactive = false }: {
21+
rating: number;
22+
size?: "sm" | "md" | "lg";
23+
interactive?: boolean;
24+
}) {
25+
const sizeConfig: Record<string, string> = {
26+
sm: "h-4 w-4",
27+
md: "h-5 w-5",
28+
lg: "h-6 w-6",
29+
};
30+
const stars = [];
31+
32+
for (let i = 0; i < 5; i++) {
33+
const filled = interactive ? i < rating : i < Math.floor(rating);
34+
stars.push(
35+
<Star
36+
key={i}
37+
className={cn(
38+
sizeConfig[size],
39+
filled ? "fill-amber-400 text-amber-400" : "text-muted-foreground/40"
40+
)}
41+
/>
42+
);
43+
}
44+
45+
return (
46+
<div className="flex items-center gap-1">
47+
{stars}
48+
{!interactive && rating > 0 && (
49+
<span className="ml-1 text-xs font-medium text-foreground">
50+
{rating.toFixed(1)}
51+
</span>
52+
)}
53+
</div>
54+
);
55+
}
56+
57+
export function ReviewCard({ review }: { review: Review }) {
58+
const formatDate = (dateStr: string) => {
59+
const date = new Date(dateStr);
60+
return date.toLocaleDateString("en-US", {
61+
month: "short",
62+
day: "numeric",
63+
year: "numeric",
64+
});
65+
};
66+
67+
return (
68+
<article className="group rounded-xl border border-border/60 bg-card/50 p-5 backdrop-blur-sm transition-all duration-300 hover:border-border/80 hover:bg-card/70">
69+
<header className="mb-4 flex items-start justify-between">
70+
<div className="flex items-start gap-3">
71+
{review.reviewerAvatar ? (
72+
<img
73+
src={review.reviewerAvatar}
74+
alt={review.reviewerName}
75+
className="h-10 w-10 rounded-full object-cover border border-border/50"
76+
/>
77+
) : (
78+
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-muted/50 border border-border/50">
79+
<User className="h-5 w-5 text-muted-foreground" />
80+
</div>
81+
)}
82+
<div className="flex-1 min-w-0">
83+
<div className="flex items-center gap-2">
84+
<p className="font-semibold text-foreground truncate">
85+
{review.reviewerName}
86+
</p>
87+
{review.verified && (
88+
<span className="flex items-center gap-1 text-xs text-emerald-400 font-medium">
89+
<ShieldCheck className="h-3.5 w-3.5" /> Verified
90+
</span>
91+
)}
92+
</div>
93+
<div className="flex items-center gap-2 text-xs text-muted-foreground mt-0.5">
94+
<Calendar className="h-3.5 w-3.5" />
95+
<span>{formatDate(review.createdAt)}</span>
96+
</div>
97+
</div>
98+
</div>
99+
100+
<div className="flex items-center gap-2">
101+
<RatingStars rating={review.rating} size="md" />
102+
</div>
103+
</header>
104+
105+
{review.comment && (
106+
<p className="mt-3 text-sm text-muted-foreground leading-relaxed">
107+
{review.comment}
108+
</p>
109+
)}
110+
111+
<footer className="mt-4 flex items-center justify-between border-t border-border/40 pt-4">
112+
<div className="text-xs text-muted-foreground">
113+
Contract #{review.contractId}
114+
</div>
115+
</footer>
116+
</article>
117+
);
118+
}

0 commit comments

Comments
 (0)