Skip to content
Open
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
30 changes: 30 additions & 0 deletions src/app/reputation/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { getMockReputationScore } from "@/lib/reputation";
import ReputationScoreCard from "@/components/ReputationScoreCard";
import ReviewHistory from "@/components/ReviewHistory";

export default function ReputationPage() {
const score = getMockReputationScore();

return (
<div className="max-w-6xl mx-auto px-4 py-8 space-y-8">
{/* Page Header */}
<div>
<h1 className="text-3xl font-bold text-navy">Reputation Dashboard</h1>
<p className="text-muted mt-1">
View your aggregate reputation score and browse through provider reviews.
</p>
</div>

{/* Layout: Score Card + Review History */}
<div className="grid grid-cols-1 lg:grid-cols-[380px_1fr] gap-8">
<aside className="space-y-6">
<ReputationScoreCard score={score} />
</aside>
<section>
<h2 className="text-xl font-semibold text-ink mb-4">Review History</h2>
<ReviewHistory />
</section>
</div>
</div>
);
}
111 changes: 111 additions & 0 deletions src/components/ReputationScoreCard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
"use client";

import { HiStar } from "react-icons/hi";
import Card from "@/components/ui/Card";
import type { ReputationScore } from "@/lib/reputation";

interface ReputationScoreCardProps {
score: ReputationScore;
}

function StarRating({ value, max = 5 }: { value: number; max?: number }) {
const full = Math.floor(value);
const hasHalf = value - full >= 0.25;
const stars = [];

for (let i = 1; i <= max; i++) {
if (i <= full) {
stars.push(
<HiStar key={i} size={18} className="fill-gold text-gold" aria-hidden="true" />
);
} else if (i === full + 1 && hasHalf) {
stars.push(
<span key={i} className="relative" aria-hidden="true">
<HiStar size={18} className="text-line" />
<span className="absolute inset-0 overflow-hidden" style={{ width: "50%" }}>
<HiStar size={18} className="fill-gold text-gold" />
</span>
</span>
);
} else {
stars.push(
<HiStar key={i} size={18} className="text-line" aria-hidden="true" />
);
}
}

return (
<div className="flex items-center gap-0.5" role="img" aria-label={`${value} out of ${max} stars`}>
{stars}
</div>
);
}

function CategoryBar({ label, value }: { label: string; value: number }) {
const pct = (value / 5) * 100;
return (
<div className="flex items-center gap-3">
<span className="w-28 text-sm text-muted shrink-0">{label}</span>
<div className="flex-1 h-2 rounded-full bg-line overflow-hidden">
<div
className="h-full rounded-full bg-gold transition-all duration-500"
style={{ width: `${pct}%` }}
/>
</div>
<span className="w-8 text-right text-sm font-semibold text-ink tabular-nums">
{value.toFixed(1)}
</span>
</div>
);
}

export default function ReputationScoreCard({ score }: ReputationScoreCardProps) {
return (
<Card className="p-6 space-y-6">
{/* Overall Score */}
<div className="text-center">
<div className="text-5xl font-bold text-navy tabular-nums">{score.overall}</div>
<StarRating value={score.overall} />
<p className="text-sm text-muted mt-1">
Based on {score.totalReviews} review{score.totalReviews !== 1 ? "s" : ""}
</p>
</div>

{/* Rating Distribution */}
<div className="space-y-1.5">
{[
{ label: "5★", count: score.fiveStar },
{ label: "4★", count: score.fourStar },
{ label: "3★", count: score.threeStar },
{ label: "2★", count: score.twoStar },
{ label: "1★", count: score.oneStar },
].map(({ label, count }) => {
const pct = score.totalReviews > 0 ? (count / score.totalReviews) * 100 : 0;
return (
<div key={label} className="flex items-center gap-2 text-sm">
<span className="w-8 text-muted">{label}</span>
<div className="flex-1 h-2 rounded-full bg-line overflow-hidden">
<div
className="h-full rounded-full bg-navy/60 transition-all duration-500"
style={{ width: `${pct}%` }}
/>
</div>
<span className="w-8 text-right text-muted tabular-nums">{count}</span>
</div>
);
})}
</div>

{/* Category Breakdown */}
<div className="pt-4 border-t border-line space-y-3">
<h4 className="text-sm font-semibold text-ink/80 uppercase tracking-wide">
By Category
</h4>
<CategoryBar label="Quality" value={score.categories.quality} />
<CategoryBar label="Punctuality" value={score.categories.punctuality} />
<CategoryBar label="Communication" value={score.categories.communication} />
<CategoryBar label="Cleanliness" value={score.categories.cleanliness} />
</div>
</Card>
);
}
125 changes: 125 additions & 0 deletions src/components/ReviewHistory.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
"use client";

import { useState, useMemo } from "react";
import { HiStar, HiChevronLeft, HiChevronRight } from "react-icons/hi";
import Card from "@/components/ui/Card";
import {
getMockReviews,
getMockReputationScore,
REVIEW_CATEGORIES,
type ReviewCategory,
} from "@/lib/reputation";

function ReviewCard({ review }: { review: import("@/lib/reputation").Review }) {
return (
<Card className="p-5 space-y-3">
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-navy-tint flex items-center justify-center text-navy font-bold text-sm">
{review.reviewerName.charAt(0)}
</div>
<div>
<p className="text-sm font-semibold text-ink">{review.reviewerName}</p>
<p className="text-xs text-muted">{review.jobTitle}</p>
</div>
</div>
<div className="flex items-center gap-0.5">
{Array.from({ length: 5 }, (_, i) => (
<HiStar
key={i}
size={14}
className={i < review.rating ? "fill-gold text-gold" : "text-line"}
aria-hidden="true"
/>
))}
</div>
</div>
<p className="text-sm text-ink/80 leading-relaxed">{review.comment}</p>
<time className="text-xs text-muted block">
{new Date(review.createdAt).toLocaleDateString("en-US", {
year: "numeric",
month: "long",
day: "numeric",
})}
</time>
</Card>
);
}

export default function ReviewHistory() {
const [category, setCategory] = useState<ReviewCategory>("all");
const [page, setPage] = useState(1);

const score = useMemo(() => getMockReputationScore(), []);
const { reviews, totalPages, total } = useMemo(
() => getMockReviews(category, page),
[category, page]
);

const handleCategoryChange = (newCategory: ReviewCategory) => {
setCategory(newCategory);
setPage(1);
};

return (
<div className="space-y-6">
{/* Filter */}
<div className="flex flex-wrap gap-2">
{REVIEW_CATEGORIES.map((cat) => (
<button
key={cat.value}
onClick={() => handleCategoryChange(cat.value)}
className={`px-4 py-2 rounded-full text-sm font-medium transition-colors ${
category === cat.value
? "bg-navy text-white"
: "bg-line/50 text-muted hover:bg-line hover:text-ink"
}`}
>
{cat.label}
</button>
))}
</div>

{/* Review list */}
{reviews.length === 0 ? (
<div className="text-center py-12 text-muted">
<p>No reviews found for this category.</p>
</div>
) : (
<div className="space-y-4">
{reviews.map((review) => (
<ReviewCard key={review.id} review={review} />
))}
</div>
)}

{/* Pagination */}
{totalPages > 1 && (
<div className="flex items-center justify-center gap-4 pt-2">
<button
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page <= 1}
className="inline-flex items-center gap-1 px-3 py-2 rounded-lg text-sm font-medium text-muted hover:text-ink hover:bg-line/50 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
aria-label="Previous page"
>
<HiChevronLeft size={16} />
Previous
</button>
<span className="text-sm text-muted tabular-nums">
Page {page} of {totalPages}
<span className="ml-2 text-xs text-muted/60">({total} total)</span>
</span>
<button
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
disabled={page >= totalPages}
className="inline-flex items-center gap-1 px-3 py-2 rounded-lg text-sm font-medium text-muted hover:text-ink hover:bg-line/50 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
aria-label="Next page"
>
Next
<HiChevronRight size={16} />
</button>
</div>
)}
</div>
);
}
Loading