style: add radar chart & result detail card UI
This commit is contained in:
parent
125c18a000
commit
5879a4ef95
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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">
|
||||
“{item.description}”
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
};
|
||||
|
|
@ -323,3 +323,7 @@ export interface VisiblePageProps {
|
|||
totalPages: number;
|
||||
currentPage: number;
|
||||
}
|
||||
|
||||
export interface RadarProps {
|
||||
data: any[];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in New Issue