Files
frontend/components/organisms/manager/ReviewsTab.tsx
T
2026-05-14 15:57:15 +00:00

155 lines
4.8 KiB
TypeScript

"use client";
import { useManager } from "@/lib/manager-context";
import { useEffect, useState } from "react";
import type { ReviewEntity } from "./Manager.types";
interface FetchState {
reviews: ReviewEntity[];
loading: boolean;
error: string | null;
}
function StarRating({ rating }: { rating: number }) {
return (
<div className="flex gap-0.5">
{[1, 2, 3, 4, 5].map((star) => (
<i
key={star}
className={
star <= rating
? "fa-solid fa-star text-sm text-yellow-400"
: "fa-regular fa-star text-sm text-(--color-border)"
}
/>
))}
</div>
);
}
export default function ReviewsTab() {
const { eateryId } = useManager();
const [{ reviews, loading, error }, setState] = useState<FetchState>({
reviews: [],
loading: true,
error: null,
});
useEffect(() => {
if (!eateryId) return;
const controller = new AbortController();
fetch(`/api/eatery/review/${eateryId}`, { signal: controller.signal })
.then((res) => {
if (!res.ok) throw new Error(`Failed to load reviews: ${res.status}`);
return res.json() as Promise<ReviewEntity[]>;
})
.then((data) => setState({ reviews: data, loading: false, error: null }))
.catch((err: unknown) => {
if (err instanceof Error && err.name === "AbortError") return;
setState({
reviews: [],
loading: false,
error: err instanceof Error ? err.message : "Failed to load reviews.",
});
});
return () => controller.abort();
}, [eateryId]);
if (loading) {
return (
<div className="flex items-center justify-center py-20 text-(--color-text-muted)">
<i className="fa-solid fa-spinner fa-spin mr-2"></i>
Loading reviews...
</div>
);
}
if (error) {
return (
<div className="flex items-center justify-center py-20 text-red-500">
<i className="fa-solid fa-triangle-exclamation mr-2"></i>
{error}
</div>
);
}
if (reviews.length === 0) {
return (
<div className="flex flex-col items-center justify-center py-20 text-center text-(--color-text-muted)">
<i className="fa-regular fa-star mb-3 text-4xl"></i>
<p className="text-sm font-medium">No reviews yet</p>
<p className="mt-1 text-xs">Reviews from customers will appear here.</p>
</div>
);
}
const avgRating =
reviews.reduce((sum, r) => sum + r.rating, 0) / reviews.length;
return (
<div className="space-y-4">
{/* Summary header */}
<div className="flex items-center gap-4 rounded-2xl border border-(--color-border-light) bg-white p-5">
<div className="flex h-14 w-14 shrink-0 items-center justify-center rounded-2xl bg-(--color-accent-light)">
<i className="fa-solid fa-star text-2xl text-yellow-400"></i>
</div>
<div>
<p className="text-foreground text-2xl font-bold">
{avgRating.toFixed(1)}
<span className="ml-1 text-sm font-normal text-(--color-text-muted)">
/ 5
</span>
</p>
<StarRating rating={Math.round(avgRating)} />
<p className="mt-0.5 text-xs text-(--color-text-muted)">
{reviews.length} review{reviews.length !== 1 ? "s" : ""}
</p>
</div>
</div>
{/* Review list */}
<div className="space-y-3">
{reviews.map((review, idx) => (
<div
key={review.id ?? idx}
className="rounded-2xl border border-(--color-border-light) bg-white p-4"
>
<div className="mb-2 flex items-start justify-between gap-3">
<div className="flex items-center gap-2">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-(--color-accent-light)">
<i className="fa-solid fa-user text-xs text-(--color-primary)"></i>
</div>
<span className="text-sm font-medium text-(--color-text-secondary)">
{review.reviewerId}
</span>
</div>
<div className="flex shrink-0 flex-col items-end gap-1">
<StarRating rating={review.rating} />
{review.createdAt && (
<span className="text-xs text-(--color-text-muted)">
{new Date(review.createdAt).toLocaleDateString("vi-VN")}
</span>
)}
</div>
</div>
{review.comment ? (
<p className="text-sm leading-relaxed text-(--color-text-secondary)">
{review.comment}
</p>
) : (
<p className="text-sm text-(--color-text-muted) italic">
No comment provided.
</p>
)}
</div>
))}
</div>
</div>
);
}