feat: add optional product review URL
This commit is contained in:
parent
f18838ee13
commit
9762069aa4
|
|
@ -1,339 +1,5 @@
|
|||
"use client";
|
||||
|
||||
import { Button } from "@/src/components/ui/button";
|
||||
import { Input } from "@/src/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/src/components/ui/select";
|
||||
import { useAnalyseText } from "@/src/hooks/useAnalyzeText";
|
||||
import { CheckCircle2, Sparkles, Star, Trophy } from "lucide-react";
|
||||
import { motion } from "framer-motion";
|
||||
import AnalysisClient from "@/src/components/dashboards/AnalysisClient";
|
||||
|
||||
export default function AnalysisPage() {
|
||||
const {
|
||||
url1,
|
||||
url2,
|
||||
profession,
|
||||
loading,
|
||||
result,
|
||||
disabled,
|
||||
handleAnalyze,
|
||||
setProfession,
|
||||
setUrl1,
|
||||
setUrl2,
|
||||
setDisabled,
|
||||
} = useAnalyseText();
|
||||
|
||||
const getSentimentTone = (score: number) => {
|
||||
if (score >= 80) return "strong";
|
||||
if (score >= 60) return "light";
|
||||
return "neutral";
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full mx-auto">
|
||||
<div className="bg-white p-6 rounded-lg border">
|
||||
<div className="mb-4 flex items-center gap-2">
|
||||
<Sparkles className="h-5 w-5 text-primary" />
|
||||
<h3 className="text-lg font-semibold">Analisis Sentimen Real-time</h3>
|
||||
</div>
|
||||
<div className="flex w-full gap-4">
|
||||
<div className="w-1/2">
|
||||
<label className="block mb-1 text-sm font-medium text-gray-700">
|
||||
Pilih Profesi/Kebutuhan:
|
||||
</label>
|
||||
<Select value={profession} onValueChange={setProfession} required>
|
||||
<SelectTrigger
|
||||
className={`w-full mb-6 ${!profession ? "text-gray-500" : "text-black"}`}
|
||||
>
|
||||
<SelectValue placeholder="-- Pilih Profesi/Kebutuhan --" />
|
||||
</SelectTrigger>
|
||||
|
||||
<SelectContent
|
||||
className="bg-card border-border shadow-lg"
|
||||
position="popper"
|
||||
>
|
||||
<SelectItem className="cursor-pointer" value="programmer">
|
||||
Programmer
|
||||
</SelectItem>
|
||||
<SelectItem className="cursor-pointer" value="designer">
|
||||
Designer
|
||||
</SelectItem>
|
||||
<SelectItem className="cursor-pointer" value="student">
|
||||
Student
|
||||
</SelectItem>
|
||||
<SelectItem className="cursor-pointer" value="gamer">
|
||||
Gamer
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="w-1/2">
|
||||
<label className="block mb-1 text-sm font-medium text-gray-700">
|
||||
Link Produk Utama
|
||||
</label>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Contoh: https://www.tokopedia.com/lenovo/thinkpad-x1-carbon"
|
||||
value={url1}
|
||||
onChange={(e) => setUrl1(e.target.value)}
|
||||
className="border p-2 rounded-md focus:ring-2 focus:ring-green-500"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex w-full gap-4 items-end">
|
||||
<div className="w-1/2">
|
||||
<label className="block mb-1 text-sm font-medium text-gray-700">
|
||||
Link Produk Pembanding
|
||||
</label>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Contoh: https://www.tokopedia.com/lenovo/thinkpad-x1-carbon"
|
||||
value={url2}
|
||||
onChange={(e) => setUrl2(e.target.value)}
|
||||
className="border p-2 rounded-md focus:ring-2 focus:ring-green-500"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="w-1/2 h-max">
|
||||
<Button className="w-full bg-[#F2F8FF] text-primary hover:text-white">
|
||||
+ Tambah Tautan Produk Lainnya
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleAnalyze}
|
||||
disabled={!url1 || !url2 || !profession || loading}
|
||||
className={`bg-primary cursor-pointer text-white px-6 py-3 mt-6 rounded-md w-max transition-colors disabled:bg-gray-400`}
|
||||
>
|
||||
<Sparkles className="h-4 w-4 text-white" />
|
||||
{loading
|
||||
? "Sedang Mengambil Ulasan & Menganalisis..."
|
||||
: "Bandingkan Sekarang"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{result && (
|
||||
<motion.div
|
||||
className="mt-12 mx-auto"
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
variants={{
|
||||
hidden: { opacity: 0 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
transition: { staggerChildren: 0.15 },
|
||||
},
|
||||
}}
|
||||
>
|
||||
{/* ================= HEADER WINNER ================= */}
|
||||
<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-gradient-to-r from-emerald-900 via-green-800 to-emerald-900 text-white p-8 rounded-2xl text-center mb-12 relative overflow-hidden shadow-2xl shadow-emerald-900/30"
|
||||
>
|
||||
<div className="absolute top-0 right-0 -mt-6 -mr-6 w-32 h-32 bg-emerald-400/20 rounded-full blur-3xl"></div>
|
||||
|
||||
<div className="relative z-10">
|
||||
<div className="inline-flex items-center gap-2 bg-white/10 backdrop-blur px-4 py-1.5 rounded-full border border-white/20 mb-4">
|
||||
<Trophy className="w-4 h-4 text-emerald-300" />
|
||||
<span className="text-sm font-semibold tracking-wide uppercase">
|
||||
Rekomendasi Terbaik
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<h2 className="text-3xl md:text-4xl font-extrabold mb-2 tracking-tight">
|
||||
{result.winning_product}
|
||||
</h2>
|
||||
|
||||
<p className="text-emerald-200 text-lg">
|
||||
Pilihan paling tepat untuk{" "}
|
||||
<span className="font-bold capitalize border-b border-emerald-400">
|
||||
{result.profession_target}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* ================= CARDS GRID ================= */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
|
||||
{result.details.map((item, index) => {
|
||||
const isWinner = item.name === result.winning_product;
|
||||
|
||||
const getSentimentTone = (score: number) => {
|
||||
if (score >= 80) return "strong";
|
||||
if (score >= 60) return "light";
|
||||
return "neutral";
|
||||
};
|
||||
|
||||
const sentimentTone = getSentimentTone(
|
||||
item.general_sentiment_score,
|
||||
);
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
key={index}
|
||||
variants={{
|
||||
hidden: { opacity: 0, y: 30 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
transition: { type: "spring", stiffness: 50 },
|
||||
},
|
||||
}}
|
||||
className={`relative group rounded-2xl border transition-all duration-500 backdrop-blur-sm hover:scale-[1.02] ${
|
||||
isWinner
|
||||
? "bg-gradient-to-br from-emerald-900 to-green-800 border-emerald-700 shadow-2xl shadow-emerald-900/30 text-white"
|
||||
: sentimentTone === "strong"
|
||||
? "bg-emerald-800 border-emerald-700 shadow-lg shadow-emerald-900/20 text-white"
|
||||
: sentimentTone === "light"
|
||||
? "bg-emerald-50 border-emerald-200 hover:shadow-lg"
|
||||
: "bg-white border-gray-200 hover:border-gray-300 hover:shadow-lg"
|
||||
}`}
|
||||
>
|
||||
{/* WINNER BADGE */}
|
||||
{isWinner && (
|
||||
<div className="absolute -top-4 left-1/2 transform -translate-x-1/2 bg-gradient-to-r from-emerald-400 to-green-300 text-emerald-900 px-4 py-1 rounded-full shadow-xl flex items-center gap-1.5 z-20 font-bold">
|
||||
<Trophy className="w-3.5 h-3.5" />
|
||||
<span className="text-xs uppercase tracking-wider">
|
||||
Top Choice
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="p-6 md:p-8 h-full flex flex-col">
|
||||
{/* PRODUCT NAME */}
|
||||
<div className="mb-6">
|
||||
<h3
|
||||
className={`font-bold text-xl leading-snug line-clamp-2 ${
|
||||
sentimentTone === "strong" || isWinner
|
||||
? "text-white"
|
||||
: "text-gray-900"
|
||||
}`}
|
||||
>
|
||||
{item.name}
|
||||
</h3>
|
||||
|
||||
<a
|
||||
href={item.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={`text-xs mt-2 inline-block transition-colors ${
|
||||
sentimentTone === "strong" || isWinner
|
||||
? "text-emerald-200 hover:text-white"
|
||||
: "text-gray-400 hover:text-green-600"
|
||||
}`}
|
||||
>
|
||||
Lihat di Tokopedia ↗
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{/* COMPATIBILITY SCORE */}
|
||||
<div className="mb-6">
|
||||
<div className="flex justify-between items-end mb-2">
|
||||
<span className="text-sm font-medium opacity-70">
|
||||
Kecocokan Profesi
|
||||
</span>
|
||||
<span className="text-lg font-bold">
|
||||
{item.profession_compatibility_score}%
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="w-full bg-black/10 rounded-full h-3 overflow-hidden">
|
||||
<motion.div
|
||||
initial={{ width: 0 }}
|
||||
animate={{
|
||||
width: `${item.profession_compatibility_score}%`,
|
||||
}}
|
||||
transition={{ duration: 1, ease: "easeOut" }}
|
||||
className={`h-full rounded-full ${
|
||||
isWinner
|
||||
? "bg-gradient-to-r from-emerald-400 to-green-300"
|
||||
: "bg-gradient-to-r from-emerald-500 to-emerald-400"
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* SENTIMENT SCORE */}
|
||||
<div className="mb-6">
|
||||
<div className="flex justify-between items-end mb-2">
|
||||
<span className="text-sm font-medium opacity-70">
|
||||
Sentimen Publik
|
||||
</span>
|
||||
<span className="text-sm font-semibold">
|
||||
{item.general_sentiment_score}% Positif
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`w-full rounded-full h-2 overflow-hidden ${
|
||||
sentimentTone === "strong"
|
||||
? "bg-emerald-900/40"
|
||||
: sentimentTone === "light"
|
||||
? "bg-emerald-100"
|
||||
: "bg-gray-100"
|
||||
}`}
|
||||
>
|
||||
<motion.div
|
||||
initial={{ width: 0 }}
|
||||
animate={{
|
||||
width: `${item.general_sentiment_score}%`,
|
||||
}}
|
||||
transition={{ duration: 1, delay: 0.3 }}
|
||||
className={`h-full rounded-full ${
|
||||
sentimentTone === "strong"
|
||||
? "bg-gradient-to-r from-emerald-400 to-emerald-300"
|
||||
: sentimentTone === "light"
|
||||
? "bg-emerald-400"
|
||||
: "bg-gray-300"
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* KEYWORDS */}
|
||||
<div className="mt-auto pt-6 border-t border-black/10">
|
||||
<p className="text-xs font-semibold opacity-60 uppercase tracking-wider mb-3">
|
||||
Kata Kunci Dominan
|
||||
</p>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{item.top_keywords.map((kw, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className={`text-xs px-3 py-1 rounded-full font-medium transition-all ${
|
||||
sentimentTone === "strong"
|
||||
? "bg-white/10 text-emerald-200 border border-white/20"
|
||||
: sentimentTone === "light"
|
||||
? "bg-emerald-100 text-emerald-700 border border-emerald-200"
|
||||
: "bg-gray-50 text-gray-600 border border-gray-100"
|
||||
}`}
|
||||
>
|
||||
#{kw}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
return <AnalysisClient />;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,165 @@
|
|||
"use client";
|
||||
|
||||
import { useAnalyseText } from "@/src/hooks/useAnalyzeText";
|
||||
import { Sparkles, Trophy } from "lucide-react";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "../ui/select";
|
||||
import { Input } from "../ui/input";
|
||||
import { Button } from "../ui/button";
|
||||
import { motion } from "framer-motion";
|
||||
import { useState } from "react";
|
||||
import ResultSection from "./ResultSection";
|
||||
|
||||
export default function AnalysisClient() {
|
||||
const {
|
||||
url1,
|
||||
url2,
|
||||
url3,
|
||||
profession,
|
||||
loading,
|
||||
result,
|
||||
disabled,
|
||||
handleAnalyze,
|
||||
setProfession,
|
||||
setUrl1,
|
||||
setUrl2,
|
||||
setDisabled,
|
||||
setUrl3,
|
||||
} = useAnalyseText();
|
||||
const [showField, setShowField] = useState(false);
|
||||
|
||||
const getSentimentTone = (score: number) => {
|
||||
if (score >= 80) return "strong";
|
||||
if (score >= 60) return "light";
|
||||
return "neutral";
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full mx-auto">
|
||||
<div className="bg-white p-6 rounded-lg border mb-8">
|
||||
<div className="mb-4 flex items-center gap-2">
|
||||
<Sparkles className="h-5 w-5 text-primary" />
|
||||
<h3 className="text-lg font-semibold">Analisis Sentimen Real-time</h3>
|
||||
</div>
|
||||
<div className="flex w-full gap-4">
|
||||
<div className="w-1/2">
|
||||
<label className="block mb-1 text-sm font-medium text-gray-700">
|
||||
Pilih Profesi/Kebutuhan:
|
||||
</label>
|
||||
<Select value={profession} onValueChange={setProfession} required>
|
||||
<SelectTrigger
|
||||
className={`w-full mb-6 ${!profession ? "text-gray-500" : "text-black"}`}
|
||||
>
|
||||
<SelectValue placeholder="-- Pilih Profesi/Kebutuhan --" />
|
||||
</SelectTrigger>
|
||||
|
||||
<SelectContent
|
||||
className="bg-card border-border shadow-lg"
|
||||
position="popper"
|
||||
>
|
||||
<SelectItem className="cursor-pointer" value="programmer">
|
||||
Programmer
|
||||
</SelectItem>
|
||||
<SelectItem className="cursor-pointer" value="designer">
|
||||
Designer
|
||||
</SelectItem>
|
||||
<SelectItem className="cursor-pointer" value="student">
|
||||
Student
|
||||
</SelectItem>
|
||||
<SelectItem className="cursor-pointer" value="gamer">
|
||||
Gamer
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="w-1/2">
|
||||
<label className="block mb-1 text-sm font-medium text-gray-700">
|
||||
Tautan Produk 1
|
||||
</label>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Contoh: https://www.tokopedia.com/lenovo/thinkpad-x1-carbon"
|
||||
value={url1}
|
||||
onChange={(e) => setUrl1(e.target.value)}
|
||||
className="border rounded-md focus:ring-2 focus:ring-green-500"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex w-full gap-4 items-end">
|
||||
<div className="w-1/2">
|
||||
<label className="block mb-1 text-sm font-medium text-gray-700">
|
||||
Tautan Produk 2
|
||||
</label>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Contoh: https://www.tokopedia.com/..."
|
||||
value={url2}
|
||||
onChange={(e) => setUrl2(e.target.value)}
|
||||
className="border rounded-md focus:ring-2 focus:ring-green-500 w-full"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{showField ? (
|
||||
<div className="w-1/2 animate-in fade-in slide-in-from-left-2 duration-300">
|
||||
<label className="block mb-1 text-sm font-medium text-gray-700">
|
||||
Tautan Produk 3
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Contoh: https://www.tokopedia.com/..."
|
||||
value={url3}
|
||||
onChange={(e) => setUrl3(e.target.value)}
|
||||
className="border p-2 rounded-md focus:ring-2 focus:ring-green-500 w-full"
|
||||
required
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setShowField(false);
|
||||
setUrl3("");
|
||||
}}
|
||||
className="text-red-500 hover:text-red-700 hover:bg-red-50 shrink-0"
|
||||
>
|
||||
✕
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-1/2 ">
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => setShowField(true)}
|
||||
className="w-full bg-[#F8FBFF] text-primary hover:text-white border-dashed border border-primary/20 hover:bg-primary transition-all"
|
||||
>
|
||||
+ Tambah Tautan Produk Lainnya
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={handleAnalyze}
|
||||
disabled={!url1 || !url2 || !profession || loading}
|
||||
className={`bg-primary cursor-pointer text-white px-6 py-3 mt-6 rounded-md w-max transition-colors disabled:bg-gray-400`}
|
||||
>
|
||||
<Sparkles className="h-4 w-4 text-white" />
|
||||
{loading
|
||||
? "Sedang Mengambil Ulasan & Menganalisis..."
|
||||
: "Bandingkan Sekarang"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<ResultSection result={result} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -18,6 +18,7 @@ import SentimentForm from "./SentimentAnalyzer";
|
|||
import { WordCloud } from "./WordCloud";
|
||||
import AnalysisPage from "@/src/app/analyze/page";
|
||||
import SentimentAnalyzer from "./SentimentAnalyzer";
|
||||
import AnalysisClient from "./AnalysisClient";
|
||||
|
||||
export default function DashboardClient() {
|
||||
const {
|
||||
|
|
@ -33,7 +34,7 @@ export default function DashboardClient() {
|
|||
} = useDashboards();
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<div className="min-h-screen bg-[#F8FBFF]">
|
||||
<Header />
|
||||
|
||||
<main className="container mx-auto px-4 py-8">
|
||||
|
|
@ -133,7 +134,7 @@ export default function DashboardClient() {
|
|||
|
||||
<div className="mb-8">
|
||||
{/* <SentimentAnalyzer /> */}
|
||||
<AnalysisPage />
|
||||
<AnalysisClient />
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ export function Header() {
|
|||
|
||||
if (!mounted) return null;
|
||||
return (
|
||||
<header className="border-b bg-card/50 backdrop-blur-sm sticky top-0 z-50">
|
||||
<header className="border-b bg-[#F8FBFF]/50 backdrop-blur-sm sticky top-0 z-50">
|
||||
<div className="container mx-auto px-4 py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<Link href="/" className="flex items-center gap-3 cursor-pointer">
|
||||
|
|
|
|||
|
|
@ -0,0 +1,212 @@
|
|||
import { AnalysisResults, ResultProps } from "@/src/types";
|
||||
import { motion } from "framer-motion";
|
||||
import { Trophy, ExternalLink, CheckCircle2, TrendingUp } from "lucide-react";
|
||||
|
||||
export default function ResultSection({ result }: ResultProps) {
|
||||
if (!result) return null;
|
||||
|
||||
const getGridClass = (count: number) => {
|
||||
if (count === 1) return "max-w-md mx-auto";
|
||||
if (count === 2) return "grid-cols-1 md:grid-cols-2";
|
||||
return "grid-cols-1 md:grid-cols-2 lg:grid-cols-3";
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className="w-full mx-auto"
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
variants={{
|
||||
hidden: { opacity: 0 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
transition: { staggerChildren: 0.15 },
|
||||
},
|
||||
}}
|
||||
>
|
||||
{/* ================= HEADER WINNER ================= */}
|
||||
<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"
|
||||
>
|
||||
{/* Dekorasi Background Abstrak */}
|
||||
<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 z-10">
|
||||
<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>
|
||||
</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>
|
||||
</motion.div>
|
||||
|
||||
{/* ================= CARDS GRID ================= */}
|
||||
<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"
|
||||
}`}
|
||||
>
|
||||
{/* WINNER BADGE (Floating) */}
|
||||
{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 z-20">
|
||||
<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">
|
||||
{/* 1. HEADER KARTU */}
|
||||
<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>
|
||||
|
||||
{/* 2. STATISTIK UTAMA (Progress Bars) */}
|
||||
<div className="space-y-5 mb-8">
|
||||
{/* Kecocokan Profesi */}
|
||||
<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>
|
||||
|
||||
{/* Sentimen Publik */}
|
||||
<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>
|
||||
|
||||
{/* 3. FOOTER (Keywords) */}
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@ import { AnalysisResults } from "../types";
|
|||
export const useAnalyseText = () => {
|
||||
const [url1, setUrl1] = useState("");
|
||||
const [url2, setUrl2] = useState("");
|
||||
const [url3, setUrl3] = useState("");
|
||||
const [profession, setProfession] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [result, setResult] = useState<AnalysisResults | null>(null);
|
||||
|
|
@ -14,11 +15,27 @@ export const useAnalyseText = () => {
|
|||
setResult(null);
|
||||
|
||||
try {
|
||||
const scrapePromises = [url1, url2].map((u) =>
|
||||
const urlsToScrape = [url1, url2, url3].filter(
|
||||
(url) => url && url.trim() !== "",
|
||||
);
|
||||
|
||||
if (urlsToScrape.length < 2) {
|
||||
alert("Produk Utama dan minimal 1 Produk Pembanding wajib diisi!");
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const scrapePromises = urlsToScrape.map((u) =>
|
||||
fetch("/api/scrape", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ url: u }),
|
||||
}).then((res) => res.json()),
|
||||
}).then((res) => {
|
||||
if (!res.ok) throw new Error(`Gagal scraping: ${u}`);
|
||||
return res.json();
|
||||
}),
|
||||
);
|
||||
|
||||
const scrapeResults = await Promise.all(scrapePromises);
|
||||
|
|
@ -59,6 +76,7 @@ export const useAnalyseText = () => {
|
|||
return {
|
||||
url1,
|
||||
url2,
|
||||
url3,
|
||||
profession,
|
||||
loading,
|
||||
result,
|
||||
|
|
@ -67,6 +85,7 @@ export const useAnalyseText = () => {
|
|||
setProfession,
|
||||
setUrl1,
|
||||
setUrl2,
|
||||
setUrl3,
|
||||
setDisabled,
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -194,3 +194,7 @@ export interface AnalysisResults {
|
|||
winning_product: string;
|
||||
details: ProductDetail[];
|
||||
}
|
||||
|
||||
export interface ResultProps {
|
||||
result: AnalysisResults | null;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue