style: add radar chart & result detail card UI

This commit is contained in:
Mahen 2026-03-03 09:56:06 +07:00
parent 125c18a000
commit 5879a4ef95
7 changed files with 285 additions and 188 deletions

View File

@ -2,22 +2,12 @@
import { useAnalyseText } from "@/src/hooks/useAnalyzeText";
import { Sparkles } from "lucide-react";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "../ui/select";
import { Input } from "../ui/input";
import { Button } from "../ui/button";
import ResultSection from "./ResultSection";
import { professionItems } from "@/src/utils/const";
import { Controller } from "react-hook-form";
export default function AnalysisClient() {
const {
control,
register,
handleSubmit,
onSubmit,
@ -43,7 +33,7 @@ export default function AnalysisClient() {
<div className="flex flex-col gap-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="w-full">
{/* <div className="w-full">
<label className="block mb-1 text-sm font-medium text-gray-700">
Pilih Profesi
</label>
@ -79,7 +69,7 @@ export default function AnalysisClient() {
{errors.profession.message}
</p>
)}
</div>
</div> */}
<div className="w-full">
<label className="block mb-1 text-sm font-medium text-gray-700">

View File

@ -0,0 +1,72 @@
import { RadarProps } from "@/src/types";
import { radarFormat } from "@/src/utils/datas";
import {
Radar,
RadarChart,
PolarGrid,
PolarAngleAxis,
PolarRadiusAxis,
ResponsiveContainer,
Legend,
Tooltip,
} from "recharts";
const RadarComparisonChart = ({ data }: RadarProps) => {
const { chartData, colors } = radarFormat({ data });
return (
<div className="w-1/2 h-100 bg-card p-5 rounded-xl border items-center flex flex-col">
<h3 className="text-lg font-semibold text-center">
Perbandingan Aspek Produk
</h3>
<ResponsiveContainer width="100%" height="100%" className="border-none">
<RadarChart cx="50%" cy="46%" outerRadius="80%" data={chartData}>
<PolarGrid />
<PolarAngleAxis dataKey="subject" className="text-xs" />
<PolarRadiusAxis
angle={90}
domain={[0, 100]}
tickCount={6}
axisLine={false}
tick={{ fontSize: 10, fill: "#94a3b8" }}
/>
{data.map((product, index) => (
<Radar
key={product.name}
name={product.name}
dataKey={product.name}
stroke={colors[index % colors.length]}
fill={colors[index % colors.length]}
fillOpacity={0.15}
dot={{ r: 2, fillOpacity: 1 }}
/>
))}
<Tooltip
contentStyle={{
borderRadius: "12px",
border: "none",
boxShadow: "0 4px 12px rgba(0,0,0,0.1)",
}}
/>
<Legend
iconSize={10}
wrapperStyle={{
paddingTop: "20px",
fontSize: "10px",
fontWeight: 600,
}}
formatter={(value) => (
<span className="text-[10px] text-gray-500 uppercase tracking-wider">
{value}
</span>
)}
/>
</RadarChart>
</ResponsiveContainer>
</div>
);
};
export default RadarComparisonChart;

View File

@ -0,0 +1,131 @@
import { useResultDetails } from "@/src/hooks/useResultDetails";
import { ResultProps } from "@/src/types";
import { getHighlights, toTitleCase } from "@/src/utils/datas";
import {
AlertCircle,
ChevronLeft,
ChevronRight,
ExternalLink,
Trophy,
} from "lucide-react";
export default function ResultDetails({ result }: ResultProps) {
const {
activeProductIndex = 0,
totalProducts = 0,
nextProduct,
prevProduct,
} = useResultDetails({ result }) || {};
if (!result || !result.details || result.details.length === 0) return null;
return (
<div className="space-y-6 w-1/2">
<div className="relative group border p-8 rounded-xl bg-card h-100 overflow-hidden">
{activeProductIndex > 0 && (
<button
onClick={prevProduct}
className="absolute left-4 top-1/2 -translate-y-1/2 p-2 rounded-full cursor-pointer bg-secondary text-primary hover:bg-primary hover:text-white transition-all z-2 shadow-md animate-in fade-in zoom-in duration-300"
aria-label="Previous Product"
>
<ChevronLeft size={24} />
</button>
)}
{[...result.details]
.sort((a, b) => {
if (a.name === result.winning_product) return -1;
if (b.name === result.winning_product) return 1;
return 0;
})
.map((item: any, index: number) => {
if (index !== activeProductIndex) return null;
const { strongest, weakest } = getHighlights(item.aspect_scores);
const isPositive = item.general_score > 70;
const isNegative = item.general_score < 40;
let bgClass = isPositive
? "bg-sentiment-positive-light text-sentiment-positive border-sentiment-positive/20"
: isNegative
? "bg-sentiment-negative-light text-sentiment-negative border-sentiment-negative/20"
: "bg-sentiment-neutral-light text-sentiment-neutral border-sentiment-neutral/20";
return (
<div
key={`${item.name}-${index}`}
className="animate-in slide-in-from-right-5 fade-in duration-500"
>
<div className="flex justify-between items-start mb-8">
<div className="max-w-[70%]">
<span className="text-[10px] font-bold text-gray-400 uppercase tracking-[0.2em]">
Produk {index + 1} dari {totalProducts}
</span>
<h4 className="font-bold text-2xl text-gray-800 mt-1 line-clamp-2">
{toTitleCase(item.name)}
</h4>
<a
href={item.url}
target="_blank"
rel="noopener noreferrer"
className="text-primary text-xs font-semibold inline-flex items-center mt-2 hover:underline gap-1"
>
Buka di Tokopedia <ExternalLink size={12} />
</a>
</div>
<div
className={`px-5 py-2 rounded-2xl text-[11px] font-black uppercase tracking-widest border shrink-0 ${bgClass}`}
>
{item.verdict}
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-8">
<div className="bg-blue-50 p-4 rounded-2xl border border-blue-100 flex items-center gap-4">
<div className="p-2 bg-white rounded-xl text-blue-500 shadow-sm shrink-0">
<Trophy size={20} />
</div>
<div className="min-w-0">
<p className="text-[10px] uppercase font-black text-blue-500 truncate">
Kekuatan Utama
</p>
<p className="text-base font-bold text-gray-700 capitalize truncate">
{strongest[0]}: {strongest[1]}%
</p>
</div>
</div>
<div className="bg-orange-50 p-4 rounded-2xl border border-orange-100 flex items-center gap-4">
<div className="p-2 bg-white rounded-xl text-orange-500 shadow-sm shrink-0">
<AlertCircle size={20} />
</div>
<div className="min-w-0">
<p className="text-[10px] uppercase font-black text-orange-500 truncate">
Perlu Diperhatikan
</p>
<p className="text-base font-bold text-gray-700 capitalize truncate">
{weakest[0]}: {weakest[1]}%
</p>
</div>
</div>
</div>
<div className="bg-secondary/50 p-5 rounded-2xl border border-blue-50 italic text-gray-600 text-sm leading-relaxed mb-4">
&ldquo;{item.description}&rdquo;
</div>
</div>
);
})}
{activeProductIndex < totalProducts - 1 && (
<button
onClick={nextProduct}
className="absolute right-4 top-1/2 -translate-y-1/2 p-2 rounded-full cursor-pointer bg-secondary text-primary hover:bg-primary hover:text-white transition-all z-2 shadow-md animate-in fade-in zoom-in duration-300"
aria-label="Next Product"
>
<ChevronRight size={24} />
</button>
)}
</div>
</div>
);
}

View File

@ -1,11 +1,9 @@
import { ResultProps } from "@/src/types";
import { getGridClass } from "@/src/utils/datas";
import { motion } from "framer-motion";
import { Trophy, ExternalLink, CheckCircle2, TrendingUp } from "lucide-react";
export default function ResultSection({ result }: ResultProps) {
if (!result) return null;
import RadarComparisonChart from "./RadarComparisonChart";
import ResultDetails from "./ResultDetails";
export default function Resultection({ result }: ResultProps) {
return (
<motion.div
className="w-full mx-auto"
@ -19,180 +17,30 @@ export default function ResultSection({ result }: ResultProps) {
},
}}
>
<motion.div
variants={{
hidden: { opacity: 0, y: -20, height: 0 },
visible: {
opacity: 1,
y: 0,
height: "auto",
transition: { duration: 0.6, ease: "easeOut" },
},
}}
className="bg-sentiment-positive text-card p-8 rounded-2xl text-center mb-12 relative overflow-hidden"
>
<div className="absolute top-0 right-0 -mt-10 -mr-10 w-64 h-64 bg-white/10 rounded-full blur-3xl"></div>
<div className="absolute bottom-0 left-0 -mb-10 -ml-10 w-40 h-40 bg-primary/20 rounded-full blur-3xl"></div>
<div className="relative">
<div className="inline-flex items-center gap-2 bg-white/10 backdrop-blur-md px-4 py-1.5 rounded-full border border-white/20 mb-6 shadow-sm">
<Trophy className="w-4 h-4 text-yellow-300" />
<span className="text-sm font-semibold tracking-wide uppercase text-white">
Rekomendasi Terbaik
</span>
{result && (
<div className="space-y-8 animate-in fade-in duration-700">
<div className="p-8 rounded-xl text-white shadow-xl flex flex-col md:flex-row justify-between items-center gap-4 bg-primary">
<div>
<p className="mx-auto text-lg text-white/80">
Rekomendasi Terbaik Berdasarkan Analisis
</p>
<h2 className="mb-2 text-xl font-bold text-white md:text-2xl">
{result.winning_product}
</h2>
</div>
<div className="bg-card backdrop-blur-md py-2 px-4 rounded-md text-primary">
<p className="mx-auto text-sm font-semibold">
{result.analysis_type.replace(/_/g, " ")}
</p>
</div>
</div>
<h2 className="text-3xl md:text-4xl font-extrabold mb-3 tracking-tight">
{result.winning_product}
</h2>
<p className="text-sentiment-positive-light/90 text-lg max-w-2xl mx-auto">
Pilihan paling tepat dan efisien untuk kebutuhan{" "}
<span className="font-bold capitalize text-white border-b-2 border-white/30 pb-0.5">
{result.profession_target}
</span>
</p>
<div className="flex gap-4">
<RadarComparisonChart data={result.details} />
<ResultDetails result={result} />
</div>
</div>
</motion.div>
<div className={`grid gap-8 ${getGridClass(result.details.length)}`}>
{result.details.map((item, index) => {
const isWinner = item.name === result.winning_product;
return (
<motion.div
key={index}
variants={{
hidden: { opacity: 0, y: 30 },
visible: {
opacity: 1,
y: 0,
transition: { type: "spring", stiffness: 50 },
},
}}
className={`relative flex flex-col justify-between rounded-2xl border transition-all duration-300 group ${
isWinner
? "bg-white border-sentiment-positive"
: "bg-white border-gray-200 hover:border-primary/50 hover:shadow-lg"
}`}
>
{isWinner && (
<div className="absolute -top-4 left-1/2 transform -translate-x-1/2 bg-sentiment-positive text-white px-6 py-1.5 rounded-full shadow-lg flex items-center gap-2">
<Trophy className="w-4 h-4 fill-yellow-400 text-yellow-400" />
<span className="text-xs font-bold uppercase tracking-wider">
Pemenang
</span>
</div>
)}
<div className="p-6 md:p-8 flex flex-col h-full">
<div className="mb-6">
<div className="flex justify-between items-start gap-4">
<h3
className={`font-bold text-xl leading-snug line-clamp-2 ${
isWinner ? "text-sentiment-positive" : "text-gray-800"
}`}
>
{item.name}
</h3>
<span
className={`shrink-0 px-2 py-1 rounded text-[10px] font-bold uppercase border ${
item.verdict.includes("Sangat Cocok")
? "bg-sentiment-positive-light/20 text-sentiment-positive border-sentiment-positive/20"
: "bg-gray-100 text-gray-500 border-gray-200"
}`}
>
{item.verdict.split("")}{" "}
</span>
</div>
<a
href={item.url}
target="_blank"
rel="noopener noreferrer"
className="group/link flex items-center gap-1 text-xs font-medium text-gray-400 hover:text-primary mt-3 transition-colors w-max"
>
Lihat Produk{" "}
<ExternalLink className="w-3 h-3 group-hover/link:translate-x-0.5 transition-transform" />
</a>
</div>
<div className="space-y-5 mb-8">
<div>
<div className="flex justify-between items-end mb-2">
<span className="text-sm font-medium text-gray-600 flex items-center gap-1.5">
<CheckCircle2
className={`w-4 h-4 ${isWinner ? "text-sentiment-positive" : "text-gray-400"}`}
/>
Kecocokan
</span>
<span
className={`text-lg font-bold ${isWinner ? "text-sentiment-positive" : "text-gray-900"}`}
>
{item.profession_compatibility_score}%
</span>
</div>
<div className="w-full bg-gray-100 rounded-full h-3 overflow-hidden">
<motion.div
initial={{ width: 0 }}
animate={{
width: `${item.profession_compatibility_score}%`,
}}
transition={{ duration: 1, ease: "circOut" }}
className={`h-full rounded-full ${
isWinner ? "bg-sentiment-positive" : "bg-primary"
}`}
/>
</div>
</div>
<div>
<div className="flex justify-between items-end mb-2">
<span className="text-sm font-medium text-gray-600 flex items-center gap-1.5">
<TrendingUp className="w-4 h-4 text-gray-400" />
Sentimen
</span>
<span className="text-sm font-semibold text-gray-700">
{item.general_sentiment_score}% Positif
</span>
</div>
<div className="w-full bg-gray-100 rounded-full h-1.5 overflow-hidden">
<motion.div
initial={{ width: 0 }}
animate={{ width: `${item.general_sentiment_score}%` }}
transition={{ duration: 1, delay: 0.3 }}
className={`h-full rounded-full opacity-60 ${
isWinner ? "bg-sentiment-positive" : "bg-primary"
}`}
/>
</div>
</div>
</div>
<div className="mt-auto pt-5 border-t border-gray-100">
<p className="text-xs font-semibold text-gray-400 uppercase tracking-wider mb-3">
Kata Kunci
</p>
<div className="flex flex-wrap gap-2">
{item.top_keywords.map((kw, i) => (
<span
key={i}
className={`text-xs px-2.5 py-1 rounded-md font-medium border transition-colors ${
isWinner
? "bg-sentiment-positive-light/20 text-sentiment-positive border-sentiment-positive/20"
: "bg-gray-50 text-gray-600 border-gray-100"
}`}
>
#{kw}
</span>
))}
</div>
</div>
</div>
</motion.div>
);
})}
</div>
)}
</motion.div>
);
}

View File

@ -0,0 +1,24 @@
import { useState } from "react";
import { ResultProps } from "../types";
export const useResultDetails = ({ result }: ResultProps) => {
const [activeProductIndex, setActiveProductIndex] = useState(0);
if (!result || !result.details || result.details.length === 0) return null;
const totalProducts = result.details.length;
const nextProduct = () => {
if (activeProductIndex < totalProducts - 1) {
setActiveProductIndex((prev) => prev + 1);
}
};
const prevProduct = () => {
if (activeProductIndex > 0) {
setActiveProductIndex((prev) => prev - 1);
}
};
return { activeProductIndex, totalProducts, nextProduct, prevProduct };
};

View File

@ -323,3 +323,7 @@ export interface VisiblePageProps {
totalPages: number;
currentPage: number;
}
export interface RadarProps {
data: any[];
}

View File

@ -1,5 +1,6 @@
import { useReviewTable } from "../hooks/useReviewTable";
import {
RadarProps,
ScrapeResult,
VisiblePageProps,
WordCloudConfig,
@ -114,3 +115,30 @@ export const getGridClass = (count: number) => {
if (count === 2) return "grid-cols-1 md:grid-cols-2";
return "grid-cols-1 md:grid-cols-2 lg:grid-cols-3";
};
export const getHighlights = (aspectScores: Record<string, number>) => {
const entries = Object.entries(aspectScores);
if (entries.length === 0)
return { strongest: ["N/A", 0], weakest: ["N/A", 0] };
const strongest = entries.reduce((a, b) => (a[1] > b[1] ? a : b));
const weakest = entries.reduce((a, b) => (a[1] < b[1] ? a : b));
return { strongest, weakest };
};
export const radarFormat = ({ data }: RadarProps) => {
const subjects = ["performa", "layar", "baterai", "harga"];
const chartData = subjects.map((subject) => {
const entry: any = { subject: subject.toUpperCase() };
data.forEach((product) => {
entry[product.name] = product.aspect_scores[subject];
});
return entry;
});
const colors = ["#8884d8", "#82ca9d", "#ffc658", "#ff7300"];
return { chartData, colors };
};