237 lines
7.5 KiB
TypeScript
237 lines
7.5 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useEffect } from "react";
|
|
import { ThumbsUp, ThumbsDown, ExternalLink, MessageSquare } from "lucide-react";
|
|
import { cn } from "@/lib/utils";
|
|
|
|
interface SteamReview {
|
|
recommendationid: string;
|
|
author: {
|
|
steamid: string;
|
|
playtime_forever: number;
|
|
};
|
|
language: string;
|
|
review: string;
|
|
timestamp_created: number;
|
|
voted_up: boolean;
|
|
votes_up: number;
|
|
votes_funny: number;
|
|
}
|
|
|
|
interface SteamReviewsData {
|
|
success: number;
|
|
query_summary: {
|
|
num_reviews: number;
|
|
review_score_desc: string;
|
|
total_positive: number;
|
|
total_negative: number;
|
|
total_reviews: number;
|
|
};
|
|
reviews: SteamReview[];
|
|
}
|
|
|
|
interface SteamReviewsProps {
|
|
gameId: string;
|
|
steamAppId: number;
|
|
className?: string;
|
|
}
|
|
|
|
export function SteamReviews({ gameId, steamAppId, className }: SteamReviewsProps) {
|
|
const [data, setData] = useState<SteamReviewsData | null>(null);
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [offset, setOffset] = useState(0);
|
|
|
|
const fetchReviews = async (newOffset: number) => {
|
|
setLoading(true);
|
|
setError(null);
|
|
|
|
try {
|
|
const res = await fetch(
|
|
`/api/steam-reviews/${gameId}?offset=${newOffset}&limit=5&language=english`
|
|
);
|
|
|
|
if (!res.ok) {
|
|
const errData = await res.json().catch(() => null);
|
|
throw new Error(errData?.error || `HTTP ${res.status}`);
|
|
}
|
|
|
|
const result = await res.json();
|
|
setData(result);
|
|
setOffset(newOffset);
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : "Unknown error");
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
fetchReviews(0);
|
|
}, [gameId]);
|
|
|
|
// Loading skeleton
|
|
if (loading && !data) {
|
|
return (
|
|
<div className={cn("animate-pulse space-y-4", className)}>
|
|
<div className="h-8 w-48 rounded bg-zinc-800" />
|
|
<div className="space-y-3">
|
|
{[...Array(3)].map((_, i) => (
|
|
<div key={i} className="h-24 rounded-lg bg-zinc-800/50" />
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Error state
|
|
if (error) {
|
|
return (
|
|
<div className={cn("rounded-lg border border-zinc-800 p-4", className)}>
|
|
<div className="flex items-center gap-2 text-zinc-400">
|
|
<MessageSquare className="h-4 w-4" />
|
|
<p className="text-sm">Steam reviews unavailable</p>
|
|
</div>
|
|
<p className="mt-1 text-xs text-zinc-500">{error}</p>
|
|
<a
|
|
href={`https://store.steampowered.com/app/${steamAppId}#app_reviews_hash`}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="mt-2 inline-flex items-center gap-1 text-xs text-blue-400 hover:text-blue-300"
|
|
>
|
|
View on Steam <ExternalLink className="h-3 w-3" />
|
|
</a>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// No data or no summary
|
|
if (!data?.query_summary) {
|
|
return (
|
|
<div className={cn("rounded-lg border border-zinc-800 p-4", className)}>
|
|
<div className="flex items-center gap-2 text-zinc-400">
|
|
<MessageSquare className="h-4 w-4" />
|
|
<p className="text-sm">No Steam reviews available</p>
|
|
</div>
|
|
<a
|
|
href={`https://store.steampowered.com/app/${steamAppId}#app_reviews_hash`}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="mt-2 inline-flex items-center gap-1 text-xs text-blue-400 hover:text-blue-300"
|
|
>
|
|
View on Steam <ExternalLink className="h-3 w-3" />
|
|
</a>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const { query_summary: summary } = data;
|
|
const totalReviews = summary.total_reviews ?? 0;
|
|
const totalPositive = summary.total_positive ?? 0;
|
|
const positivePercent =
|
|
totalReviews > 0
|
|
? Math.round((totalPositive / totalReviews) * 100)
|
|
: 0;
|
|
|
|
// No reviews case
|
|
if (totalReviews === 0) {
|
|
return (
|
|
<div className={cn("rounded-lg border border-zinc-800 p-4", className)}>
|
|
<div className="flex items-center gap-2 text-zinc-400">
|
|
<MessageSquare className="h-4 w-4" />
|
|
<p className="text-sm">No reviews yet</p>
|
|
</div>
|
|
<a
|
|
href={`https://store.steampowered.com/app/${steamAppId}#app_reviews_hash`}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="mt-2 inline-flex items-center gap-1 text-xs text-blue-400 hover:text-blue-300"
|
|
>
|
|
Be the first to review on Steam <ExternalLink className="h-3 w-3" />
|
|
</a>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className={cn("space-y-4", className)}>
|
|
{/* Summary header */}
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<h3 className="text-lg font-semibold">Steam Reviews</h3>
|
|
<p className="text-sm text-zinc-400">
|
|
{summary.review_score_desc ?? "No reviews"} — {positivePercent}% positive ({totalReviews.toLocaleString()} reviews)
|
|
</p>
|
|
</div>
|
|
<a
|
|
href={`https://store.steampowered.com/app/${steamAppId}#app_reviews_hash`}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="flex items-center gap-1 text-sm text-blue-400 hover:text-blue-300"
|
|
>
|
|
View on Steam <ExternalLink className="h-3 w-3" />
|
|
</a>
|
|
</div>
|
|
|
|
{/* Review cards */}
|
|
<div className="space-y-3">
|
|
{(data.reviews ?? []).map((review) => (
|
|
<div
|
|
key={review.recommendationid}
|
|
className="rounded-lg border border-zinc-800 bg-zinc-900/50 p-4"
|
|
>
|
|
<div className="mb-2 flex items-center justify-between">
|
|
<div className="flex items-center gap-2">
|
|
{review.voted_up ? (
|
|
<ThumbsUp className="h-4 w-4 text-green-400" />
|
|
) : (
|
|
<ThumbsDown className="h-4 w-4 text-red-400" />
|
|
)}
|
|
<span className={cn(
|
|
"text-sm font-medium",
|
|
review.voted_up ? "text-green-400" : "text-red-400"
|
|
)}>
|
|
{review.voted_up ? "Recommended" : "Not Recommended"}
|
|
</span>
|
|
</div>
|
|
<span className="text-xs text-zinc-500">
|
|
{Math.floor((review.author?.playtime_forever ?? 0) / 60)}h played
|
|
</span>
|
|
</div>
|
|
<p className="text-sm text-zinc-300 line-clamp-4">{review.review}</p>
|
|
<div className="mt-2 flex items-center gap-3 text-xs text-zinc-500">
|
|
<span>{review.votes_up} found helpful</span>
|
|
{review.votes_funny > 0 && (
|
|
<span>{review.votes_funny} funny</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
{/* Pagination */}
|
|
{totalReviews > 5 && (
|
|
<div className="flex justify-between">
|
|
<button
|
|
onClick={() => fetchReviews(Math.max(0, offset - 5))}
|
|
disabled={offset === 0 || loading}
|
|
className="rounded-md border border-zinc-700 px-3 py-1.5 text-sm text-zinc-300 hover:bg-zinc-800 disabled:opacity-50"
|
|
>
|
|
Previous
|
|
</button>
|
|
<span className="text-xs text-zinc-500 self-center">
|
|
Showing {offset + 1}-{Math.min(offset + 5, totalReviews)} of {totalReviews}
|
|
</span>
|
|
<button
|
|
onClick={() => fetchReviews(offset + 5)}
|
|
disabled={offset + 5 >= totalReviews || loading}
|
|
className="rounded-md border border-zinc-700 px-3 py-1.5 text-sm text-zinc-300 hover:bg-zinc-800 disabled:opacity-50"
|
|
>
|
|
Next
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|