Compare commits
10 Commits
a810cb1a45
...
0496071600
| Author | SHA1 | Date |
|---|---|---|
|
|
0496071600 | |
|
|
277c9c9b7e | |
|
|
de44a74dde | |
|
|
130c69eede | |
|
|
3c8b8a32ad | |
|
|
be20832b19 | |
|
|
9d9e14e092 | |
|
|
c9edc4a3fc | |
|
|
9369a29086 | |
|
|
54fd6ac26b |
|
|
@ -97,7 +97,7 @@ model User {
|
||||||
bio String? @db.Text
|
bio String? @db.Text
|
||||||
|
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt @default(now())
|
||||||
|
|
||||||
accounts Account[]
|
accounts Account[]
|
||||||
sessions Session[]
|
sessions Session[]
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,6 @@ import { Input } from "../ui/input";
|
||||||
import { Button } from "../ui/button";
|
import { Button } from "../ui/button";
|
||||||
import ResultSection from "./ResultSection";
|
import ResultSection from "./ResultSection";
|
||||||
import UrlInputList from "./UrlInputList";
|
import UrlInputList from "./UrlInputList";
|
||||||
import { useTheme } from "@/src/context/ThemeContext";
|
|
||||||
|
|
||||||
export default function AnalysisClient() {
|
export default function AnalysisClient() {
|
||||||
const {
|
const {
|
||||||
|
|
@ -18,13 +17,13 @@ export default function AnalysisClient() {
|
||||||
progress,
|
progress,
|
||||||
visibleFields,
|
visibleFields,
|
||||||
urlDatas,
|
urlDatas,
|
||||||
|
darkMode,
|
||||||
register,
|
register,
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
handleCancel,
|
handleCancel,
|
||||||
setVisibleFields,
|
setVisibleFields,
|
||||||
} = useAnalyseText();
|
} = useAnalyseText();
|
||||||
const { darkMode } = useTheme();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full mx-auto">
|
<div className="w-full mx-auto">
|
||||||
|
|
@ -39,7 +38,7 @@ export default function AnalysisClient() {
|
||||||
<h3
|
<h3
|
||||||
className={`text-lg font-semibold ${darkMode ? "text-white" : "text-black"} transition-all duration-500`}
|
className={`text-lg font-semibold ${darkMode ? "text-white" : "text-black"} transition-all duration-500`}
|
||||||
>
|
>
|
||||||
Analisis Sentimen Real-time
|
Analisis Sentimen
|
||||||
</h3>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,554 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback } from "react";
|
||||||
|
import { createPortal } from "react-dom";
|
||||||
|
import {
|
||||||
|
X,
|
||||||
|
Info,
|
||||||
|
Database,
|
||||||
|
Brain,
|
||||||
|
Eye,
|
||||||
|
AlertCircle,
|
||||||
|
ChevronDown,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { motion, AnimatePresence } from "framer-motion";
|
||||||
|
|
||||||
|
interface AspectScoreInfoProps {
|
||||||
|
isDark: boolean;
|
||||||
|
aspectScores: Record<string, number>;
|
||||||
|
totalReviews: number;
|
||||||
|
positiveCount: number;
|
||||||
|
negativeCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ASPECT_KEYWORD_SAMPLES: Record<string, string[]> = {
|
||||||
|
performa: ["cepat", "kencang", "lancar", "lag", "lemot", "gaming", "render"],
|
||||||
|
layar: [
|
||||||
|
"jernih",
|
||||||
|
"tajam",
|
||||||
|
"cerah",
|
||||||
|
"resolusi",
|
||||||
|
"oled",
|
||||||
|
"refresh rate",
|
||||||
|
"dead pixel",
|
||||||
|
],
|
||||||
|
baterai: [
|
||||||
|
"awet",
|
||||||
|
"tahan lama",
|
||||||
|
"boros",
|
||||||
|
"cepat habis",
|
||||||
|
"cas",
|
||||||
|
"charging",
|
||||||
|
"mah",
|
||||||
|
],
|
||||||
|
harga: [
|
||||||
|
"murah",
|
||||||
|
"mahal",
|
||||||
|
"worth it",
|
||||||
|
"terjangkau",
|
||||||
|
"promo",
|
||||||
|
"diskon",
|
||||||
|
"budget",
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const ASPECT_ICONS: Record<string, string> = {
|
||||||
|
performa: "⚡",
|
||||||
|
layar: "🖥️",
|
||||||
|
baterai: "🔋",
|
||||||
|
harga: "💰",
|
||||||
|
};
|
||||||
|
|
||||||
|
function ScoreBar({ score, isDark }: { score: number; isDark: boolean }) {
|
||||||
|
const barColor =
|
||||||
|
score >= 80 ? "bg-green-500" : score >= 60 ? "bg-yellow-500" : "bg-red-500";
|
||||||
|
const textColor =
|
||||||
|
score >= 80
|
||||||
|
? "text-green-500"
|
||||||
|
: score >= 60
|
||||||
|
? "text-yellow-500"
|
||||||
|
: "text-red-500";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2 w-full">
|
||||||
|
<div
|
||||||
|
className={`flex-1 h-1.5 rounded-full ${isDark ? "bg-gray-700" : "bg-gray-100"}`}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`h-1.5 rounded-full transition-all duration-700 ${barColor}`}
|
||||||
|
style={{ width: `${score}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
className={`text-xs font-mono font-semibold w-9 text-right ${textColor}`}
|
||||||
|
>
|
||||||
|
{score.toFixed(0)}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatusBadge({ score, isDark }: { score: number; isDark: boolean }) {
|
||||||
|
if (score >= 80)
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={`text-[10px] px-1.5 py-0.5 rounded font-medium ${isDark ? "bg-green-900/40 text-green-400" : "bg-green-50 text-green-600"}`}
|
||||||
|
>
|
||||||
|
✓ Unggul
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
if (score >= 60)
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={`text-[10px] px-1.5 py-0.5 rounded font-medium ${isDark ? "bg-yellow-900/40 text-yellow-400" : "bg-yellow-50 text-yellow-600"}`}
|
||||||
|
>
|
||||||
|
⚠ Perhatian
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={`text-[10px] px-1.5 py-0.5 rounded font-medium ${isDark ? "bg-red-900/40 text-red-400" : "bg-red-50 text-red-600"}`}
|
||||||
|
>
|
||||||
|
✗ Lemah
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Divider({ isDark }: { isDark: boolean }) {
|
||||||
|
return (
|
||||||
|
<div className={`h-px w-full ${isDark ? "bg-gray-700" : "bg-gray-100"}`} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SectionLabel({
|
||||||
|
children,
|
||||||
|
isDark,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
isDark: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<p
|
||||||
|
className={`text-[10px] font-bold uppercase tracking-widest mb-3 ${isDark ? "text-gray-500" : "text-gray-400"}`}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AspectScoreInfo({
|
||||||
|
isDark,
|
||||||
|
aspectScores,
|
||||||
|
totalReviews,
|
||||||
|
positiveCount,
|
||||||
|
negativeCount,
|
||||||
|
}: AspectScoreInfoProps) {
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const [activeStep, setActiveStep] = useState<number | null>(null);
|
||||||
|
const [mounted, setMounted] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setMounted(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const close = useCallback(() => {
|
||||||
|
setIsOpen(false);
|
||||||
|
setActiveStep(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) return;
|
||||||
|
const handleKey = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === "Escape") close();
|
||||||
|
};
|
||||||
|
window.addEventListener("keydown", handleKey);
|
||||||
|
return () => window.removeEventListener("keydown", handleKey);
|
||||||
|
}, [isOpen, close]);
|
||||||
|
|
||||||
|
const generalScore =
|
||||||
|
totalReviews > 0 ? ((positiveCount / totalReviews) * 100).toFixed(1) : "0";
|
||||||
|
|
||||||
|
const steps = [
|
||||||
|
{
|
||||||
|
title: "Ulasan dipindai kata kunci per aspek",
|
||||||
|
description:
|
||||||
|
"Sistem mencari kata-kata tertentu di setiap ulasan, seperti 'awet' untuk baterai atau 'lag' untuk performa. Ulasan yang mengandung kata tersebut akan dianalisis lebih lanjut oleh sistem.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Model XGBoost mengklasifikasikan sentimen",
|
||||||
|
description:
|
||||||
|
"Ulasan yang menyebut aspek tersebut kemudian diklasifikasi oleh model prediksi. Apakah bernada positif, negatif, atau netral?",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Skor dihitung dari rasio positif",
|
||||||
|
description:
|
||||||
|
"Semakin banyak pembeli yang puas terhadap suatu aspek, semakin tinggi skornya. Misalnya jika 8 dari 10 pembeli senang dengan baterai produk ini, maka skor baterainya 80%.",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const trustReasons = [
|
||||||
|
{
|
||||||
|
icon: Database,
|
||||||
|
title: "Berbasis ulasan nyata pembeli",
|
||||||
|
desc: "Data diambil langsung dari halaman ulasan Tokopedia via scraping, bukan dari spesifikasi produk atau klaim penjual.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: Brain,
|
||||||
|
title: "Model XGBoost Optimized (akurasi 73%)",
|
||||||
|
desc: "Dilatih dengan pipeline SMOTE + seleksi fitur Chi-Square + Grid Search, divalidasi pada data terpisah.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: Eye,
|
||||||
|
title: "Cara kerja sistem dapat ditelusuri",
|
||||||
|
desc: "Setiap aspek punya daftar kata kunci tetap yang bisa diaudit. Sistem hanya menghitung proporsi dari apa yang pembeli tulis.",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const modalContent = (
|
||||||
|
<AnimatePresence>
|
||||||
|
{isOpen && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
transition={{ duration: 0.18 }}
|
||||||
|
className="fixed inset-0 z-10 flex items-center justify-center p-4"
|
||||||
|
style={{ isolation: "isolate" }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`absolute inset-0 backdrop-blur-sm ${isDark ? "bg-black/60" : "bg-black/40"}`}
|
||||||
|
onClick={close}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
``
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, scale: 0.95, y: 8 }}
|
||||||
|
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, scale: 0.95, y: 8 }}
|
||||||
|
transition={{ duration: 0.2, ease: "easeOut" }}
|
||||||
|
className={`relative w-full max-w-md max-h-[85vh] overflow-hidden rounded-2xl border shadow-2xl flex flex-col ${
|
||||||
|
isDark
|
||||||
|
? "bg-gray-800 border-gray-700"
|
||||||
|
: "bg-white border-gray-200"
|
||||||
|
}`}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label="Transparansi metodologi skor aspek"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`shrink-0 flex items-center justify-between px-5 py-4 border-b ${
|
||||||
|
isDark
|
||||||
|
? "bg-gray-800 border-gray-700"
|
||||||
|
: "bg-white border-gray-100"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Info
|
||||||
|
className={`w-4 h-4 ${isDark ? "text-blue-400" : "text-primary"}`}
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<p
|
||||||
|
className={`text-sm font-semibold leading-tight ${isDark ? "text-white" : "text-gray-800"}`}
|
||||||
|
>
|
||||||
|
Transparansi Metodologi
|
||||||
|
</p>
|
||||||
|
<p
|
||||||
|
className={`text-[11px] leading-tight ${isDark ? "text-gray-400" : "text-gray-500"}`}
|
||||||
|
>
|
||||||
|
Dari mana angka persentase ini berasal?
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={close}
|
||||||
|
aria-label="Tutup"
|
||||||
|
className={`w-7 h-7 rounded-full flex items-center justify-center transition-colors cursor-pointer ${
|
||||||
|
isDark
|
||||||
|
? "hover:bg-gray-700 text-gray-400 hover:text-white"
|
||||||
|
: "hover:bg-gray-100 text-gray-400 hover:text-gray-700"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto px-5 py-4 space-y-5">
|
||||||
|
<div>
|
||||||
|
<SectionLabel isDark={isDark}>
|
||||||
|
Skor aspek pada produk ini
|
||||||
|
</SectionLabel>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{Object.entries(aspectScores).map(([aspect, score]) => (
|
||||||
|
<div key={aspect} className="space-y-1.5">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<span className="text-sm">
|
||||||
|
{ASPECT_ICONS[aspect] ?? "📊"}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={`text-xs font-medium capitalize ${isDark ? "text-gray-300" : "text-gray-600"}`}
|
||||||
|
>
|
||||||
|
{aspect}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<StatusBadge score={score} isDark={isDark} />
|
||||||
|
</div>
|
||||||
|
<ScoreBar score={score} isDark={isDark} />
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{(ASPECT_KEYWORD_SAMPLES[aspect] ?? [])
|
||||||
|
.slice(0, 4)
|
||||||
|
.map((kw) => (
|
||||||
|
<span
|
||||||
|
key={kw}
|
||||||
|
className={`text-[10px] px-1.5 py-0.5 rounded font-mono ${
|
||||||
|
isDark
|
||||||
|
? "bg-gray-700 text-gray-400"
|
||||||
|
: "bg-gray-100 text-gray-500"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{kw}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
<span
|
||||||
|
className={`text-[10px] px-1 py-0.5 ${isDark ? "text-gray-600" : "text-gray-400"}`}
|
||||||
|
>
|
||||||
|
+ lainnya
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Divider isDark={isDark} />
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<SectionLabel isDark={isDark}>
|
||||||
|
Statistik ulasan produk ini
|
||||||
|
</SectionLabel>
|
||||||
|
<div className="grid grid-cols-3 gap-2">
|
||||||
|
{[
|
||||||
|
{
|
||||||
|
label: "Total",
|
||||||
|
value: totalReviews,
|
||||||
|
cls: isDark
|
||||||
|
? "bg-gray-700 text-white"
|
||||||
|
: "bg-gray-50 text-gray-700",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Positif",
|
||||||
|
value: `${positiveCount} (${generalScore}%)`,
|
||||||
|
cls: isDark
|
||||||
|
? "bg-green-900/40 text-green-400"
|
||||||
|
: "bg-green-50 text-green-700",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Negatif",
|
||||||
|
value: `${negativeCount} (${(100 - parseFloat(generalScore)).toFixed(1)}%)`,
|
||||||
|
cls: isDark
|
||||||
|
? "bg-red-900/40 text-red-400"
|
||||||
|
: "bg-red-50 text-red-700",
|
||||||
|
},
|
||||||
|
].map((s) => (
|
||||||
|
<div
|
||||||
|
key={s.label}
|
||||||
|
className={`rounded-lg p-3 text-center ${s.cls}`}
|
||||||
|
>
|
||||||
|
<p className="text-sm font-semibold leading-tight">
|
||||||
|
{s.value}
|
||||||
|
</p>
|
||||||
|
<p className="text-[10px] mt-0.5 opacity-75">{s.label}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={`flex items-start gap-1.5 mt-2.5 p-2.5 rounded-lg ${isDark ? "bg-gray-700/50" : "bg-amber-50"}`}
|
||||||
|
>
|
||||||
|
<AlertCircle
|
||||||
|
className={`w-3.5 h-3.5 shrink-0 mt-0.5 ${isDark ? "text-gray-400" : "text-amber-500"}`}
|
||||||
|
/>
|
||||||
|
<p
|
||||||
|
className={`text-[11px] leading-relaxed ${isDark ? "text-gray-400" : "text-amber-700"}`}
|
||||||
|
>
|
||||||
|
Aspek yang jarang disebut dalam ulasan cenderung memiliki
|
||||||
|
skor yang kurang representatif. Semakin banyak ulasan
|
||||||
|
menyebut aspek tersebut, semakin valid skornya.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Divider isDark={isDark} />
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<SectionLabel isDark={isDark}>Cara skor dihitung</SectionLabel>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{steps.map((step, i) => (
|
||||||
|
<button
|
||||||
|
key={i}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setActiveStep(activeStep === i ? null : i)}
|
||||||
|
className={`w-full text-left rounded-lg p-3 border transition-all duration-200 cursor-pointer ${
|
||||||
|
activeStep === i
|
||||||
|
? isDark
|
||||||
|
? "bg-blue-900/30 border-blue-700"
|
||||||
|
: "bg-blue-50 border-blue-200"
|
||||||
|
: isDark
|
||||||
|
? "border-gray-700 hover:bg-gray-700"
|
||||||
|
: "bg-gray-50 border-gray-100 hover:bg-gray-100"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-2.5">
|
||||||
|
<span
|
||||||
|
className={`text-[11px] font-bold w-5 h-5 rounded-full flex items-center justify-center shrink-0 mt-0.5 ${
|
||||||
|
activeStep === i
|
||||||
|
? "bg-blue-500 text-white"
|
||||||
|
: isDark
|
||||||
|
? "bg-gray-600 text-gray-300"
|
||||||
|
: "bg-gray-200 text-gray-600"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{i + 1}
|
||||||
|
</span>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p
|
||||||
|
className={`text-xs font-medium ${isDark ? "text-gray-200" : "text-gray-700"}`}
|
||||||
|
>
|
||||||
|
{step.title}
|
||||||
|
</p>
|
||||||
|
<AnimatePresence>
|
||||||
|
{activeStep === i && (
|
||||||
|
<motion.p
|
||||||
|
initial={{ opacity: 0, height: 0 }}
|
||||||
|
animate={{ opacity: 1, height: "auto" }}
|
||||||
|
exit={{ opacity: 0, height: 0 }}
|
||||||
|
transition={{ duration: 0.18 }}
|
||||||
|
className={`text-[11px] mt-1.5 leading-relaxed overflow-hidden ${isDark ? "text-gray-400" : "text-gray-500"}`}
|
||||||
|
>
|
||||||
|
{step.description}
|
||||||
|
</motion.p>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
<ChevronDown
|
||||||
|
className={`w-3.5 h-3.5 shrink-0 mt-0.5 transition-transform duration-200 ${
|
||||||
|
activeStep === i ? "rotate-180" : ""
|
||||||
|
} ${isDark ? "text-gray-500" : "text-gray-400"}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-3">
|
||||||
|
<p
|
||||||
|
className={`text-[11px] mb-1.5 ${isDark ? "text-gray-500" : "text-gray-400"}`}
|
||||||
|
>
|
||||||
|
Formula yang digunakan:
|
||||||
|
</p>
|
||||||
|
<div
|
||||||
|
className={`rounded-lg p-3 font-mono text-[11px] leading-relaxed border ${
|
||||||
|
isDark
|
||||||
|
? "bg-gray-900 text-gray-300 border-gray-700"
|
||||||
|
: "bg-gray-50 text-gray-700 border-gray-200"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={isDark ? "text-blue-400" : "text-blue-600"}
|
||||||
|
>
|
||||||
|
Skor Aspek
|
||||||
|
</span>
|
||||||
|
{" = ("}
|
||||||
|
<span
|
||||||
|
className={isDark ? "text-green-400" : "text-green-600"}
|
||||||
|
>
|
||||||
|
ulasan positif menyebut aspek
|
||||||
|
</span>
|
||||||
|
{" ÷ "}
|
||||||
|
<span
|
||||||
|
className={isDark ? "text-yellow-400" : "text-yellow-600"}
|
||||||
|
>
|
||||||
|
total ulasan menyebut aspek
|
||||||
|
</span>
|
||||||
|
{") × 100%"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Divider isDark={isDark} />
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<SectionLabel isDark={isDark}>
|
||||||
|
Mengapa angka ini bisa dipercaya?
|
||||||
|
</SectionLabel>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{trustReasons.map(({ icon: Icon, title, desc }) => (
|
||||||
|
<div key={title} className="flex gap-3 items-start">
|
||||||
|
<div
|
||||||
|
className={`w-7 h-7 rounded-lg flex items-center justify-center shrink-0 ${isDark ? "bg-blue-900/40" : "bg-blue-50"}`}
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
className={`w-3.5 h-3.5 ${isDark ? "text-blue-400" : "text-blue-500"}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p
|
||||||
|
className={`text-xs font-medium ${isDark ? "text-gray-200" : "text-gray-700"}`}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</p>
|
||||||
|
<p
|
||||||
|
className={`text-[11px] mt-0.5 leading-relaxed ${isDark ? "text-gray-500" : "text-gray-400"}`}
|
||||||
|
>
|
||||||
|
{desc}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="h-1" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={`shrink-0 px-5 py-3 border-t ${isDark ? "bg-gray-800 border-gray-700" : "bg-white border-gray-100"}`}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={close}
|
||||||
|
className={`w-full py-2 rounded-lg text-sm font-medium transition-colors cursor-pointer ${
|
||||||
|
isDark
|
||||||
|
? "bg-gray-700 text-white hover:bg-gray-600"
|
||||||
|
: "bg-gray-100 text-gray-700 hover:bg-gray-200"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Mengerti, tutup
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsOpen(true)}
|
||||||
|
className={`cursor-pointer flex items-center gap-1.5 text-xs px-3 py-1.5 rounded-lg border transition-all duration-200 mt-3 ${
|
||||||
|
isDark
|
||||||
|
? "border-gray-700 text-gray-400 hover:bg-blue-900/30 hover:text-blue-400 hover:border-blue-800"
|
||||||
|
: "border-gray-200 text-gray-500 hover:bg-blue-50 hover:text-blue-600 hover:border-blue-200"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Info className="w-3.5 h-3.5" />
|
||||||
|
<span>Dari mana angka ini berasal?</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{mounted && createPortal(modalContent, document.body)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -31,10 +31,11 @@ export default function DashboardClient() {
|
||||||
neutralCount,
|
neutralCount,
|
||||||
loading,
|
loading,
|
||||||
modelData,
|
modelData,
|
||||||
|
darkMode,
|
||||||
|
toggleDarkMode,
|
||||||
percentage,
|
percentage,
|
||||||
scrollToResult,
|
scrollToResult,
|
||||||
} = useDashboards();
|
} = useDashboards();
|
||||||
const { darkMode, toggleDarkMode } = useTheme();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|
@ -64,6 +65,12 @@ export default function DashboardClient() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<section id="analysis-form" className="scroll-mt-60">
|
||||||
|
<div className="mb-8 ">
|
||||||
|
<AnalysisClient />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<div className="mb-8 grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
<div className="mb-8 grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
<StatCard
|
<StatCard
|
||||||
title="Total Ulasan"
|
title="Total Ulasan"
|
||||||
|
|
@ -151,12 +158,6 @@ export default function DashboardClient() {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section id="analysis-form" className="scroll-mt-60">
|
|
||||||
<div className="mb-8 ">
|
|
||||||
<AnalysisClient />
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<div>
|
<div>
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import { useDashboards } from "@/src/hooks/useDashboard";
|
||||||
import { RadarProps } from "@/src/types";
|
import { RadarProps } from "@/src/types";
|
||||||
import { radarFormat } from "@/src/utils/datas";
|
import { radarFormat } from "@/src/utils/datas";
|
||||||
import {
|
import {
|
||||||
|
|
@ -13,16 +14,26 @@ import {
|
||||||
|
|
||||||
const RadarComparisonChart = ({ data }: RadarProps) => {
|
const RadarComparisonChart = ({ data }: RadarProps) => {
|
||||||
const { chartData, colors } = radarFormat({ data });
|
const { chartData, colors } = radarFormat({ data });
|
||||||
|
const { darkMode } = useDashboards();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-100 bg-card p-5 rounded-xl border items-center flex flex-col">
|
<div
|
||||||
<h3 className="text-lg font-semibold text-center">
|
className={`h-100 ${darkMode ? "bg-gray-800 border-transparent" : "bg-card"} p-5 rounded-xl border items-center flex flex-col transition-all duration-500`}
|
||||||
|
>
|
||||||
|
<h3 className="text-lg font-semibold text-center transition-all duration-500">
|
||||||
Perbandingan Aspek Produk
|
Perbandingan Aspek Produk
|
||||||
</h3>
|
</h3>
|
||||||
<ResponsiveContainer width="100%" height="100%" className="border-none">
|
<ResponsiveContainer
|
||||||
|
width="100%"
|
||||||
|
height="100%"
|
||||||
|
className="border-transparent transition-all duration-500"
|
||||||
|
>
|
||||||
<RadarChart cx="50%" cy="46%" outerRadius="80%" data={chartData}>
|
<RadarChart cx="50%" cy="46%" outerRadius="80%" data={chartData}>
|
||||||
<PolarGrid />
|
<PolarGrid />
|
||||||
<PolarAngleAxis dataKey="subject" className="text-xs" />
|
<PolarAngleAxis
|
||||||
|
dataKey="subject"
|
||||||
|
className={`${darkMode ? "text-gray-400" : "text-gray-500"} text-xs`}
|
||||||
|
/>
|
||||||
<PolarRadiusAxis
|
<PolarRadiusAxis
|
||||||
angle={90}
|
angle={90}
|
||||||
domain={[0, 100]}
|
domain={[0, 100]}
|
||||||
|
|
@ -33,13 +44,14 @@ const RadarComparisonChart = ({ data }: RadarProps) => {
|
||||||
|
|
||||||
{data.map((product, index) => (
|
{data.map((product, index) => (
|
||||||
<Radar
|
<Radar
|
||||||
key={product.name}
|
key={`${product.name}-${index}`}
|
||||||
name={product.name}
|
name={product.name}
|
||||||
dataKey={product.name}
|
dataKey={product.name}
|
||||||
stroke={colors[index % colors.length]}
|
stroke={colors[index % colors.length]}
|
||||||
fill={colors[index % colors.length]}
|
fill={colors[index % colors.length]}
|
||||||
fillOpacity={0.15}
|
fillOpacity={0.15}
|
||||||
dot={{ r: 2, fillOpacity: 1 }}
|
dot={{ r: 2, fillOpacity: 1 }}
|
||||||
|
className={`${darkMode ? "animate-in fade-in duration-500 text-card" : "animate-in fade-in duration-500"}`}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
|
|
@ -48,6 +60,8 @@ const RadarComparisonChart = ({ data }: RadarProps) => {
|
||||||
borderRadius: "12px",
|
borderRadius: "12px",
|
||||||
border: "none",
|
border: "none",
|
||||||
boxShadow: "0 4px 12px rgba(0,0,0,0.1)",
|
boxShadow: "0 4px 12px rgba(0,0,0,0.1)",
|
||||||
|
color: darkMode ? "#e0e0e0" : "#333",
|
||||||
|
backgroundColor: darkMode ? "#333" : "#fff",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Legend
|
<Legend
|
||||||
|
|
@ -58,7 +72,9 @@ const RadarComparisonChart = ({ data }: RadarProps) => {
|
||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
}}
|
}}
|
||||||
formatter={(value) => (
|
formatter={(value) => (
|
||||||
<span className="text-[10px] text-gray-500 uppercase tracking-wider">
|
<span
|
||||||
|
className={`${darkMode ? "text-card" : "text-gray-500"} text-[10px] uppercase tracking-wider transition-all duration-500`}
|
||||||
|
>
|
||||||
{value}
|
{value}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import { useDashboards } from "@/src/hooks/useDashboard";
|
||||||
import { useResultDetails } from "@/src/hooks/useResultDetails";
|
import { useResultDetails } from "@/src/hooks/useResultDetails";
|
||||||
import { ResultProps } from "@/src/types";
|
import { ResultProps } from "@/src/types";
|
||||||
import { getHighlights, toTitleCase } from "@/src/utils/datas";
|
import { getHighlights, toTitleCase } from "@/src/utils/datas";
|
||||||
|
|
@ -8,6 +9,7 @@ import {
|
||||||
ExternalLink,
|
ExternalLink,
|
||||||
Trophy,
|
Trophy,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
import AspectScoreInfo from "./AspectScoreInfo";
|
||||||
|
|
||||||
export default function ResultDetails({ result }: ResultProps) {
|
export default function ResultDetails({ result }: ResultProps) {
|
||||||
const {
|
const {
|
||||||
|
|
@ -16,16 +18,25 @@ export default function ResultDetails({ result }: ResultProps) {
|
||||||
nextProduct,
|
nextProduct,
|
||||||
prevProduct,
|
prevProduct,
|
||||||
} = useResultDetails({ result }) || {};
|
} = useResultDetails({ result }) || {};
|
||||||
|
const { darkMode } = useDashboards();
|
||||||
|
|
||||||
if (!result || !result.details || result.details.length === 0) return null;
|
if (!result || !result.details || result.details.length === 0) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="container space-y-4">
|
||||||
<div className="relative group border p-8 rounded-xl bg-card h-100 overflow-hidden">
|
<div
|
||||||
|
className={`relative group border p-8 rounded-xl ${
|
||||||
|
darkMode ? "bg-gray-800 border-transparent" : "bg-card"
|
||||||
|
} h-100 overflow-hidden transition-all duration-500`}
|
||||||
|
>
|
||||||
{activeProductIndex > 0 && (
|
{activeProductIndex > 0 && (
|
||||||
<button
|
<button
|
||||||
onClick={prevProduct}
|
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"
|
className={`${
|
||||||
|
darkMode
|
||||||
|
? "absolute left-4 top-1/2 -translate-y-1/2 p-2 rounded-full cursor-pointer bg-gray-800/30 text-card hover:bg-gray-900 hover:text-card transition-all z-2 shadow-md animate-in fade-in zoom-in duration-300"
|
||||||
|
: "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"
|
aria-label="Previous Product"
|
||||||
>
|
>
|
||||||
<ChevronLeft size={24} />
|
<ChevronLeft size={24} />
|
||||||
|
|
@ -61,14 +72,20 @@ export default function ResultDetails({ result }: ResultProps) {
|
||||||
<span className="text-[10px] font-bold text-gray-400 uppercase tracking-[0.2em]">
|
<span className="text-[10px] font-bold text-gray-400 uppercase tracking-[0.2em]">
|
||||||
Produk {index + 1} dari {totalProducts}
|
Produk {index + 1} dari {totalProducts}
|
||||||
</span>
|
</span>
|
||||||
<h4 className="font-bold text-2xl text-gray-800 mt-1 line-clamp-2">
|
<h4
|
||||||
|
className={`${
|
||||||
|
darkMode ? "text-card" : "text-gray-800"
|
||||||
|
} font-bold text-2xl mt-1 line-clamp-2 transition-all duration-500`}
|
||||||
|
>
|
||||||
{toTitleCase(item.name)}
|
{toTitleCase(item.name)}
|
||||||
</h4>
|
</h4>
|
||||||
<a
|
<a
|
||||||
href={item.url}
|
href={item.url}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="text-primary text-xs font-semibold inline-flex items-center mt-2 hover:underline gap-1"
|
className={`${
|
||||||
|
darkMode ? "text-gray-300" : "text-primary"
|
||||||
|
} text-xs font-semibold inline-flex items-center mt-2 hover:underline gap-1 transition-all duration-500`}
|
||||||
>
|
>
|
||||||
Buka di Tokopedia <ExternalLink size={12} />
|
Buka di Tokopedia <ExternalLink size={12} />
|
||||||
</a>
|
</a>
|
||||||
|
|
@ -80,7 +97,7 @@ export default function ResultDetails({ result }: ResultProps) {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-8">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
|
||||||
<div className="bg-blue-50 p-4 rounded-2xl border border-blue-100 flex items-center gap-4">
|
<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">
|
<div className="p-2 bg-white rounded-xl text-blue-500 shadow-sm shrink-0">
|
||||||
<Trophy size={20} />
|
<Trophy size={20} />
|
||||||
|
|
@ -109,9 +126,40 @@ export default function ResultDetails({ result }: ResultProps) {
|
||||||
</div>
|
</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">
|
{(() => {
|
||||||
|
const sortedDetails = [...result.details].sort((a, b) => {
|
||||||
|
if (a.name === result.winning_product) return -1;
|
||||||
|
if (b.name === result.winning_product) return 1;
|
||||||
|
return 0;
|
||||||
|
});
|
||||||
|
const activeItem = sortedDetails[activeProductIndex];
|
||||||
|
if (!activeItem) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AspectScoreInfo
|
||||||
|
isDark={darkMode}
|
||||||
|
aspectScores={
|
||||||
|
activeItem.aspect_scores as unknown as Record<
|
||||||
|
string,
|
||||||
|
number
|
||||||
|
>
|
||||||
|
}
|
||||||
|
totalReviews={activeItem.total_reviews}
|
||||||
|
positiveCount={activeItem.positive_count}
|
||||||
|
negativeCount={activeItem.negative_count}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
|
||||||
|
{/* <div
|
||||||
|
className={`${
|
||||||
|
darkMode
|
||||||
|
? "bg-gray-900 text-card border-transparent"
|
||||||
|
: "bg-secondary/50 text-gray-600"
|
||||||
|
} p-5 mt-4 rounded-2xl border border-blue-50 italic text-sm leading-relaxed mb-4 transition-all duration-500`}
|
||||||
|
>
|
||||||
“{item.description}”
|
“{item.description}”
|
||||||
</div>
|
</div> */}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
@ -119,7 +167,11 @@ export default function ResultDetails({ result }: ResultProps) {
|
||||||
{activeProductIndex < totalProducts - 1 && (
|
{activeProductIndex < totalProducts - 1 && (
|
||||||
<button
|
<button
|
||||||
onClick={nextProduct}
|
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"
|
className={`${
|
||||||
|
darkMode
|
||||||
|
? "absolute right-4 top-1/2 -translate-y-1/2 p-2 rounded-full cursor-pointer bg-gray-800/30 text-card hover:bg-gray-900 hover:text-card transition-all z-2 shadow-md animate-in fade-in zoom-in duration-300"
|
||||||
|
: "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"
|
aria-label="Next Product"
|
||||||
>
|
>
|
||||||
<ChevronRight size={24} />
|
<ChevronRight size={24} />
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,10 @@ import { ResultProps } from "@/src/types";
|
||||||
import { motion } from "framer-motion";
|
import { motion } from "framer-motion";
|
||||||
import RadarComparisonChart from "./RadarComparisonChart";
|
import RadarComparisonChart from "./RadarComparisonChart";
|
||||||
import ResultDetails from "./ResultDetails";
|
import ResultDetails from "./ResultDetails";
|
||||||
|
import { useDashboards } from "@/src/hooks/useDashboard";
|
||||||
|
|
||||||
export default function Resultection({ result }: ResultProps) {
|
export default function Resultection({ result }: ResultProps) {
|
||||||
|
const { darkMode } = useDashboards();
|
||||||
return (
|
return (
|
||||||
<motion.div
|
<motion.div
|
||||||
className="w-full mx-auto"
|
className="w-full mx-auto"
|
||||||
|
|
@ -19,7 +21,9 @@ export default function Resultection({ result }: ResultProps) {
|
||||||
>
|
>
|
||||||
{result && (
|
{result && (
|
||||||
<div className="space-y-8 animate-in fade-in duration-700">
|
<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
|
||||||
|
className={`${darkMode ? "bg-gray-800" : "bg-primary"} p-8 rounded-xl text-white shadow-xl flex flex-col md:flex-row justify-between items-center gap-4 transition-all duration-500`}
|
||||||
|
>
|
||||||
<div>
|
<div>
|
||||||
<p className="mx-auto text-lg text-white/80">
|
<p className="mx-auto text-lg text-white/80">
|
||||||
Rekomendasi Terbaik Berdasarkan Analisis
|
Rekomendasi Terbaik Berdasarkan Analisis
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ import {
|
||||||
} from "../services/analyze.service";
|
} from "../services/analyze.service";
|
||||||
import { analyzeSchema } from "../app/validation/analyze.schema";
|
import { analyzeSchema } from "../app/validation/analyze.schema";
|
||||||
import { getMetricId } from "../services/metric.service";
|
import { getMetricId } from "../services/metric.service";
|
||||||
import { getBrandId } from "../services/brand.service";
|
import { useTheme } from "../context/ThemeContext";
|
||||||
|
|
||||||
export const useAnalyseText = () => {
|
export const useAnalyseText = () => {
|
||||||
const { data: session } = useSession();
|
const { data: session } = useSession();
|
||||||
|
|
@ -19,6 +19,7 @@ export const useAnalyseText = () => {
|
||||||
const [progress, setProgress] = useState({ status: "", percent: 0 });
|
const [progress, setProgress] = useState({ status: "", percent: 0 });
|
||||||
const abortControllerRef = useRef<AbortController | null>(null);
|
const abortControllerRef = useRef<AbortController | null>(null);
|
||||||
const [visibleFields, setVisibleFields] = useState(0);
|
const [visibleFields, setVisibleFields] = useState(0);
|
||||||
|
const { darkMode, toggleDarkMode } = useTheme();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
control,
|
control,
|
||||||
|
|
@ -175,6 +176,8 @@ export const useAnalyseText = () => {
|
||||||
setResult(aiResult);
|
setResult(aiResult);
|
||||||
setProgress({ status: "Selesai", percent: 100 });
|
setProgress({ status: "Selesai", percent: 100 });
|
||||||
|
|
||||||
|
window.dispatchEvent(new CustomEvent("analysis-complete"));
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
document
|
document
|
||||||
.getElementById("analysis-result")
|
.getElementById("analysis-result")
|
||||||
|
|
@ -232,6 +235,8 @@ export const useAnalyseText = () => {
|
||||||
resultRef,
|
resultRef,
|
||||||
progress,
|
progress,
|
||||||
urlDatas,
|
urlDatas,
|
||||||
|
darkMode,
|
||||||
|
toggleDarkMode,
|
||||||
register,
|
register,
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
setValue,
|
setValue,
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import { useState, useEffect, useMemo } from "react";
|
||||||
import { ModelDB, Review, StatCounts } from "@/src/types";
|
import { ModelDB, Review, StatCounts } from "@/src/types";
|
||||||
import { getClassificationReport } from "../app/dashboard/lib/actions";
|
import { getClassificationReport } from "../app/dashboard/lib/actions";
|
||||||
import { sentimentStatsPath } from "../utils/const";
|
import { sentimentStatsPath } from "../utils/const";
|
||||||
|
import { useTheme } from "../context/ThemeContext";
|
||||||
|
|
||||||
export const useDashboards = () => {
|
export const useDashboards = () => {
|
||||||
const [selectedBrand, setSelectedBrand] = useState<string | null>(null);
|
const [selectedBrand, setSelectedBrand] = useState<string | null>(null);
|
||||||
|
|
@ -16,6 +17,7 @@ export const useDashboards = () => {
|
||||||
negative: 0,
|
negative: 0,
|
||||||
neutral: 0,
|
neutral: 0,
|
||||||
});
|
});
|
||||||
|
const { darkMode, toggleDarkMode } = useTheme();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function fetchStats() {
|
async function fetchStats() {
|
||||||
|
|
@ -42,6 +44,8 @@ export const useDashboards = () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
fetchStats();
|
fetchStats();
|
||||||
|
window.addEventListener("analysis-complete", fetchStats);
|
||||||
|
return () => window.removeEventListener("analysis-complete", fetchStats);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -57,6 +61,9 @@ export const useDashboards = () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
fetchModelData();
|
fetchModelData();
|
||||||
|
window.addEventListener("analysis-complete", fetchModelData);
|
||||||
|
return () =>
|
||||||
|
window.removeEventListener("analysis-complete", fetchModelData);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const filteredReviews = useMemo(() => {
|
const filteredReviews = useMemo(() => {
|
||||||
|
|
@ -84,6 +91,8 @@ export const useDashboards = () => {
|
||||||
selectedBrand,
|
selectedBrand,
|
||||||
loading,
|
loading,
|
||||||
modelData,
|
modelData,
|
||||||
|
darkMode,
|
||||||
|
toggleDarkMode,
|
||||||
setSelectedBrand,
|
setSelectedBrand,
|
||||||
percentage,
|
percentage,
|
||||||
scrollToResult,
|
scrollToResult,
|
||||||
|
|
|
||||||
|
|
@ -15,9 +15,19 @@ export const useReviewTable = (
|
||||||
const getReviewData = async () => {
|
const getReviewData = async () => {
|
||||||
try {
|
try {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
// 1. CEK URL SEBELUM FETCH: Pastikan tidak ada kata "undefined" di console browser
|
||||||
|
console.log("Menembak ke URL:", reviewPath);
|
||||||
|
|
||||||
const req = await fetch(reviewPath);
|
const req = await fetch(reviewPath);
|
||||||
|
|
||||||
|
// 2. CEK STATUS HTTP: Pastikan statusnya 200 OK, bukan 404 atau 500
|
||||||
|
console.log("HTTP Status:", req.status);
|
||||||
|
|
||||||
const res: ApiResponse = await req.json();
|
const res: ApiResponse = await req.json();
|
||||||
|
|
||||||
|
// 3. CEK STRUKTUR PAYLOAD: Lihat isi JSON asli dari FastAPI di deploy mode
|
||||||
|
console.log("Raw Response dari Backend:", res);
|
||||||
|
|
||||||
if (res.data && Array.isArray(res.data)) {
|
if (res.data && Array.isArray(res.data)) {
|
||||||
setData(res.data);
|
setData(res.data);
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -30,6 +40,8 @@ export const useReviewTable = (
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
getReviewData();
|
getReviewData();
|
||||||
|
window.addEventListener("analysis-complete", getReviewData);
|
||||||
|
return () => window.removeEventListener("analysis-complete", getReviewData);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
|
||||||
|
|
@ -80,6 +80,8 @@ export const useWordCloud = () => {
|
||||||
};
|
};
|
||||||
|
|
||||||
fetchWords();
|
fetchWords();
|
||||||
|
window.addEventListener("analysis-complete", fetchWords);
|
||||||
|
return () => window.removeEventListener("analysis-complete", fetchWords);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const maxValue = Math.max(...words.map((w) => w.value), 1);
|
const maxValue = Math.max(...words.map((w) => w.value), 1);
|
||||||
|
|
|
||||||
|
|
@ -71,8 +71,8 @@ export const getAIRecommendation = async (
|
||||||
},
|
},
|
||||||
options?: { signal?: AbortSignal },
|
options?: { signal?: AbortSignal },
|
||||||
): Promise<AIRecommendationResponse> => {
|
): Promise<AIRecommendationResponse> => {
|
||||||
const base_url = process.env.BACKEND_URL? process.env.BACKEND_URL.replace(/\/+$/, "") : "http://localhost:8000";
|
const base_url = process.env.NEXT_PUBLIC_API_URL;
|
||||||
|
|
||||||
const aiRes = await fetch(`${base_url}/recommend`, {
|
const aiRes = await fetch(`${base_url}/recommend`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
|
|
|
||||||
|
|
@ -81,14 +81,14 @@ const reviewDatas = [
|
||||||
|
|
||||||
const scrapePath = "/api/scrape";
|
const scrapePath = "/api/scrape";
|
||||||
// const backendUrl = process.env.BACKEND_URL || "http://localhost:8000";
|
// const backendUrl = process.env.BACKEND_URL || "http://localhost:8000";
|
||||||
const backendUrl = process.env.BACKEND_URL;
|
const backendUrl = process.env.NEXT_PUBLIC_API_URL;
|
||||||
const aiRecommendPath = `${backendUrl}/recommend`;
|
const aiRecommendPath = `${backendUrl}/recommend`;
|
||||||
const userMetricPath = "/api/user-metric";
|
const userMetricPath = "/api/user-metric";
|
||||||
const profilePath = "/api/profile";
|
const profilePath = "/api/profile";
|
||||||
const chromiumUrl =
|
const chromiumUrl =
|
||||||
"https://github.com/Sparticuz/chromium/releases/download/v131.0.1/chromium-v131.0.1-pack.tar";
|
"https://github.com/Sparticuz/chromium/releases/download/v131.0.1/chromium-v131.0.1-pack.tar";
|
||||||
const sentimentStatsPath = "/api/review/sentiment-stats";
|
const sentimentStatsPath = "/api/review/sentiment-stats";
|
||||||
const productPath = "/api/product";
|
const productPath = "/api/product"
|
||||||
const reviewPath = "/api/review";
|
const reviewPath = "/api/review";
|
||||||
const positiveWords = [
|
const positiveWords = [
|
||||||
"bagus",
|
"bagus",
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue