refactor: clean up spaghetti code

This commit is contained in:
Mahen 2026-04-05 15:32:23 +07:00
parent 169b5050a3
commit e30e126e0d
18 changed files with 160 additions and 121 deletions

View File

@ -28,10 +28,10 @@ async function main() {
modelName: "Model XGBoost (Optimized)", modelName: "Model XGBoost (Optimized)",
description: description:
"Model final menggunakan teknik SMOTE untuk menyeimbangkan kelas, seleksi fitur Chi-Square, dan optimasi Grid Search.", "Model final menggunakan teknik SMOTE untuk menyeimbangkan kelas, seleksi fitur Chi-Square, dan optimasi Grid Search.",
accuracy: 0.72, accuracy: 0.73,
macroF1: 0.66, macroF1: 0.67,
f1Negative: 0.68, f1Negative: 0.68,
f1Neutral: 0.43, f1Neutral: 0.45,
isActive: true, isActive: true,
}, },
]; ];

View File

@ -2,15 +2,12 @@ import { useCallback, useEffect, useRef, useState } from "react";
import { useSession } from "next-auth/react"; import { useSession } from "next-auth/react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { AnalysisResults, AnalyzeFormData } from "../types"; import { AnalysisResults, AnalyzeFormData } from "../types";
import { import {
scrapeProduct, scrapeProduct,
getAIRecommendation, getAIRecommendation,
} from "../services/analyze.service"; } from "../services/analyze.service";
import { analyzeSchema } from "../app/validation/analyze.schema"; // Sesuaikan path-nya import { analyzeSchema } from "../app/validation/analyze.schema";
import { getAnotherUserData } from "../app/profile/lib/action";
import prisma from "@/lib/prisma";
import { getMetricId } from "../services/metric.service"; import { getMetricId } from "../services/metric.service";
export const useAnalyseText = () => { export const useAnalyseText = () => {
@ -182,7 +179,7 @@ export const useAnalyseText = () => {
} catch (error: any) { } catch (error: any) {
if (error.name === "AbortError" || signal.aborted) { if (error.name === "AbortError" || signal.aborted) {
console.log("🛠️ Request dibatalkan secara aman."); console.log("🛠️ Request dibatalkan secara aman.");
return; // Keluar dari fungsi tanpa memunculkan alert error return;
} }
console.error("Analysis Error:", error); console.error("Analysis Error:", error);

View File

@ -3,6 +3,7 @@
import { useState, useEffect, useMemo } from "react"; 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";
export const useDashboards = () => { export const useDashboards = () => {
const [selectedBrand, setSelectedBrand] = useState<string | null>(null); const [selectedBrand, setSelectedBrand] = useState<string | null>(null);
@ -20,7 +21,7 @@ export const useDashboards = () => {
async function fetchStats() { async function fetchStats() {
setLoading(true); setLoading(true);
const res = await fetch("/api/review/sentiment-stats"); const res = await fetch(sentimentStatsPath);
const json = await res.json(); const json = await res.json();
const statsData = json.data; const statsData = json.data;

View File

@ -1,5 +1,6 @@
import { useSession } from "next-auth/react"; import { useSession } from "next-auth/react";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { productPath } from "../utils/const";
export const useHeader = () => { export const useHeader = () => {
const [isRefreshing, setIsRefreshing] = useState(false); const [isRefreshing, setIsRefreshing] = useState(false);
@ -17,7 +18,7 @@ export const useHeader = () => {
setMounted(true); setMounted(true);
const getProductCount = async () => { const getProductCount = async () => {
try { try {
const res = await fetch("/api/product"); const res = await fetch(productPath);
if (!res.ok) throw new Error("Failed to fetch product count"); if (!res.ok) throw new Error("Failed to fetch product count");
const data = await res.json(); const data = await res.json();

View File

@ -4,7 +4,7 @@ import { useState, useEffect } from "react";
import { useSession } from "next-auth/react"; import { useSession } from "next-auth/react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { ProfileClientProps, ProfileFormData, ProfileState } from "@/src/types"; import { ProfileClientProps, ProfileFormData, ProfileState } from "@/src/types";
import { Brand, OS, Profession } from "@prisma/client"; import { OS, Profession } from "@prisma/client";
import { brandFormat } from "../utils/datas"; import { brandFormat } from "../utils/datas";
export const useProfileClient = (props: ProfileClientProps) => { export const useProfileClient = (props: ProfileClientProps) => {
@ -47,7 +47,7 @@ export const useProfileClient = (props: ProfileClientProps) => {
(newData.profession as Profession) || prev.preference.profession, (newData.profession as Profession) || prev.preference.profession,
preferredBrand: preferredBrand:
(newData.preferredBrand ) || prev.preference.preferredBrand, newData.preferredBrand || prev.preference.preferredBrand,
preferredOS: (newData.preferredOS as OS) || prev.preference.preferredOS, preferredOS: (newData.preferredOS as OS) || prev.preference.preferredOS,

View File

@ -1,6 +1,7 @@
import { useEffect, useState, useMemo } from "react"; import { useEffect, useState, useMemo } from "react";
import { ApiResponse, ReviewItem } from "../types"; import { ApiResponse, ReviewItem } from "../types";
import { PaginationService } from "../services/review.service"; import { PaginationService } from "../services/review.service";
import { reviewPath } from "../utils/const";
export const useReviewTable = ( export const useReviewTable = (
itemsPerPage: number = 10, itemsPerPage: number = 10,
@ -14,7 +15,7 @@ export const useReviewTable = (
const getReviewData = async () => { const getReviewData = async () => {
try { try {
setIsLoading(true); setIsLoading(true);
const req = await fetch("/api/review"); const req = await fetch(reviewPath);
const res: ApiResponse = await req.json(); const res: ApiResponse = await req.json();
if (res.data && Array.isArray(res.data)) { if (res.data && Array.isArray(res.data)) {

View File

@ -1,6 +1,7 @@
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { AnalysisResult } from "../types"; import { AnalysisResult } from "../types";
import { Minus, ThumbsDown, ThumbsUp } from "lucide-react"; import { configDisplay } from "../utils/datas";
import { models, negativeWords, positiveWords } from "../utils/const";
export const useSentiment = () => { export const useSentiment = () => {
const [text, setText] = useState(""); const [text, setText] = useState("");
@ -23,32 +24,6 @@ export const useSentiment = () => {
await new Promise((resolve) => setTimeout(resolve, 1500)); await new Promise((resolve) => setTimeout(resolve, 1500));
const positiveWords = [
"bagus",
"cepat",
"aman",
"baik",
"mulus",
"moga",
"awet",
"mantap",
"sangat",
"fungsi",
];
const negativeWords = [
"lebih",
"jual",
"baru",
"lalu",
"tahun",
"masalah",
"rusak",
"garansi",
"layar",
"kecewa",
];
const lowerText = text.toLowerCase(); const lowerText = text.toLowerCase();
let positiveScore = 0; let positiveScore = 0;
let negativeScore = 0; let negativeScore = 0;
@ -91,53 +66,10 @@ export const useSentiment = () => {
}; };
const getSentimentDisplay = (sentiment: AnalysisResult["sentiment"]) => { const getSentimentDisplay = (sentiment: AnalysisResult["sentiment"]) => {
const config = { const config = configDisplay(sentiment);
POSITIVE: { return config;
icon: ThumbsUp,
label: "Positif",
bgClass: "bg-sentiment-positive-light",
textClass: "text-sentiment-positive",
borderClass: "border-sentiment-positive/30",
},
NEGATIVE: {
icon: ThumbsDown,
label: "Negatif",
bgClass: "bg-sentiment-negative-light",
textClass: "text-sentiment-negative",
borderClass: "border-sentiment-negative/30",
},
NEUTRAL: {
icon: Minus,
label: "Netral",
bgClass: "bg-sentiment-neutral-light",
textClass: "text-sentiment-neutral",
borderClass: "border-sentiment-neutral/30",
},
};
return config[sentiment];
}; };
const models = [
{
code: "none",
value: "xgboost",
label: "XGBoost (Baseline)",
desc: "Model 1",
},
{
code: "Grid Search",
value: "xgboost",
label: "XGBoost (Tuned)",
desc: "Model 2",
},
{
code: "recommended",
value: "xgboost",
label: "XGBoost (Fully Optimized)",
desc: "Model 3",
},
];
const filteredItems = useMemo(() => { const filteredItems = useMemo(() => {
if (!searchQuery) return models; if (!searchQuery) return models;
return models.filter( return models.filter(
@ -149,18 +81,18 @@ export const useSentiment = () => {
return { return {
selectedModel, selectedModel,
setSelectedModel,
text, text,
setText,
laptopName, laptopName,
setLaptopName,
isAnalyzing, isAnalyzing,
analyzeText,
result, result,
getSentimentDisplay,
searchQuery, searchQuery,
setSearchQuery,
filteredItems, filteredItems,
isFormValid, isFormValid,
setSelectedModel,
setText,
setLaptopName,
analyzeText,
getSentimentDisplay,
setSearchQuery,
}; };
}; };

View File

@ -1,7 +1,7 @@
"use client"; "use client";
import { useState } from "react"; import { useState } from "react";
import { MODEL_OPTIONS } from "../utils/const"; import { MODEL_OPTIONS, predictPath } from "../utils/const";
export const useSentimentForm = () => { export const useSentimentForm = () => {
const [selectedModel, setSelectedModel] = useState(MODEL_OPTIONS[2]); const [selectedModel, setSelectedModel] = useState(MODEL_OPTIONS[2]);
@ -31,7 +31,7 @@ export const useSentimentForm = () => {
setResult(null); setResult(null);
try { try {
const response = await fetch("http://127.0.0.1:8000/predict", { const response = await fetch(predictPath, {
method: "POST", method: "POST",
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",

View File

@ -1,6 +1,6 @@
// src/hooks/useSocket.ts
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { io, Socket } from "socket.io-client"; import { io, Socket } from "socket.io-client";
import { socketPath } from "../utils/const";
export const useSocket = () => { export const useSocket = () => {
const [socket, setSocket] = useState<Socket | null>(null); const [socket, setSocket] = useState<Socket | null>(null);
@ -8,7 +8,7 @@ export const useSocket = () => {
useEffect(() => { useEffect(() => {
const socketInitializer = async () => { const socketInitializer = async () => {
await fetch("/api/socket"); // Panggil API untuk menyalakan server socket await fetch(socketPath);
const newSocket = io(); const newSocket = io();
newSocket.on("progress", (data) => { newSocket.on("progress", (data) => {

View File

@ -1,6 +1,6 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { KeywordStats, WordCloudReview, WordItem } from "@/src/types"; import { KeywordStats, WordCloudReview, WordItem } from "@/src/types";
import { WORD_LIMIT } from "../utils/const"; import { WORD_LIMIT, wordCloudPath } from "../utils/const";
export const useWordCloud = () => { export const useWordCloud = () => {
const [words, setWords] = useState<WordItem[]>([]); const [words, setWords] = useState<WordItem[]>([]);
@ -11,11 +11,11 @@ export const useWordCloud = () => {
useEffect(() => { useEffect(() => {
const fetchWords = async () => { const fetchWords = async () => {
try { try {
const res = await fetch("/api/word-cloud"); const res = await fetch(wordCloudPath);
const json = await res.json(); const json = await res.json();
if (!json?.success || !Array.isArray(json.data)) { if (!json?.success || !Array.isArray(json.data)) {
console.error("Invalid response from /api/word-cloud"); console.error(`Invalid response from ${wordCloudPath}`);
return; return;
} }

View File

@ -1,11 +1,12 @@
import prisma from "@/lib/prisma"; import prisma from "@/lib/prisma";
import { AIRecommendationResponse } from "../types"; import { AIRecommendationResponse } from "../types";
import { aiRecommendPath, scrapePath } from "../utils/const";
export const scrapeProduct = async ( export const scrapeProduct = async (
url: string, url: string,
options?: { signal?: AbortSignal }, options?: { signal?: AbortSignal },
) => { ) => {
const res = await fetch("/api/scrape", { const res = await fetch(scrapePath, {
method: "POST", method: "POST",
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
@ -70,7 +71,7 @@ export const getAIRecommendation = async (
options?: { signal?: AbortSignal }, options?: { signal?: AbortSignal },
): Promise<AIRecommendationResponse> => { ): Promise<AIRecommendationResponse> => {
console.log("Fetching to FastAPI..."); console.log("Fetching to FastAPI...");
const aiRes = await fetch("https://citot123-tokped-scraper.hf.space/recommend", { const aiRes = await fetch(aiRecommendPath, {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload), body: JSON.stringify(payload),
@ -79,13 +80,7 @@ export const getAIRecommendation = async (
if (!aiRes.ok) { if (!aiRes.ok) {
const errorData = await aiRes.json(); const errorData = await aiRes.json();
// DEBUG: Munculkan di console agar bisa dibaca strukturnya
console.error(
"DETAILED VALIDATION ERROR:",
JSON.stringify(errorData, null, 2),
);
// Ambil pesan error pertama dari list validation FastAPI
const errorMessage = const errorMessage =
errorData.detail?.[0]?.msg || "Gagal melakukan analisis AI"; errorData.detail?.[0]?.msg || "Gagal melakukan analisis AI";
throw new Error(errorMessage); throw new Error(errorMessage);

View File

@ -1,7 +1,9 @@
import { userMetricPath } from "../utils/const";
export const getMetricId = async () => { export const getMetricId = async () => {
const response = await fetch("/api/user-metric"); const response = await fetch(userMetricPath);
if (!response.ok) return null; if (!response.ok) return null;
const data = await response.json(); const data = await response.json();
return data.metricId; return data.metricId;
}; };

View File

@ -1,8 +1,9 @@
import prisma from "@/lib/prisma"; import prisma from "@/lib/prisma";
import { ProfileFormData } from "../types"; import { ProfileFormData } from "../types";
import { profilePath } from "../utils/const";
export const updateProfileService = async (formData: ProfileFormData) => { export const updateProfileService = async (formData: ProfileFormData) => {
const response = await fetch("/api/profile", { const response = await fetch(profilePath, {
method: "PATCH", method: "PATCH",
body: JSON.stringify(formData), body: JSON.stringify(formData),
}); });

View File

@ -2,6 +2,7 @@ import puppeteer from "puppeteer-core";
import chromium from "@sparticuz/chromium-min"; import chromium from "@sparticuz/chromium-min";
import { ScrapeResult } from "../types"; import { ScrapeResult } from "../types";
import { getFallbackData } from "../utils/datas"; import { getFallbackData } from "../utils/datas";
import { chromiumUrl } from "../utils/const";
function normalizeToReviewUrl(rawUrl: string): string { function normalizeToReviewUrl(rawUrl: string): string {
try { try {
@ -28,8 +29,6 @@ export async function scrapeTokopediaProduct(
): Promise<ScrapeResult> { ): Promise<ScrapeResult> {
const targetUrl = normalizeToReviewUrl(url); const targetUrl = normalizeToReviewUrl(url);
let browser; let browser;
const CHROMIUM_URL =
"https://github.com/Sparticuz/chromium/releases/download/v131.0.1/chromium-v131.0.1-pack.tar";
try { try {
browser = await puppeteer.launch({ browser = await puppeteer.launch({
@ -39,7 +38,7 @@ export async function scrapeTokopediaProduct(
"--disable-setuid-sandbox", "--disable-setuid-sandbox",
"--disable-blink-features=AutomationControlled", "--disable-blink-features=AutomationControlled",
], ],
executablePath: await chromium.executablePath(CHROMIUM_URL), executablePath: await chromium.executablePath(chromiumUrl),
headless: true, headless: true,
defaultViewport: { width: 1280, height: 800 }, defaultViewport: { width: 1280, height: 800 },
}); });

View File

@ -8,9 +8,8 @@ import {
import { SiAcer, SiAsus, SiLenovo, SiLinux, SiMacos } from "react-icons/si"; import { SiAcer, SiAsus, SiLenovo, SiLinux, SiMacos } from "react-icons/si";
import { FaWindows } from "react-icons/fa"; import { FaWindows } from "react-icons/fa";
import { Sentiment } from "@prisma/client"; import { Sentiment } from "@prisma/client";
import { useAnalyseText } from "../hooks/useAnalyzeText";
export const MODEL_OPTIONS = [ const MODEL_OPTIONS = [
{ {
label: "Model XGBoost (Baseline)", label: "Model XGBoost (Baseline)",
code: "baseline", code: "baseline",
@ -28,9 +27,9 @@ export const MODEL_OPTIONS = [
}, },
]; ];
export const WORD_LIMIT = 30; const WORD_LIMIT = 30;
export const professionItems = [ const professionItems = [
{ value: "PROGRAMMER", label: "Programmer", icon: Code }, { value: "PROGRAMMER", label: "Programmer", icon: Code },
{ value: "DESIGNER", label: "Designer", icon: Palette }, { value: "DESIGNER", label: "Designer", icon: Palette },
{ value: "STUDENT", label: "Student", icon: Book }, { value: "STUDENT", label: "Student", icon: Book },
@ -38,21 +37,21 @@ export const professionItems = [
{ value: "OTHER", label: "Other", icon: LucideCircleEllipsis }, { value: "OTHER", label: "Other", icon: LucideCircleEllipsis },
]; ];
export const brandItems = [ const brandItems = [
{ value: "ASUS", label: "Asus", icon: SiAsus }, { value: "ASUS", label: "Asus", icon: SiAsus },
{ value: "ACER", label: "Acer", icon: SiAcer }, { value: "ACER", label: "Acer", icon: SiAcer },
{ value: "LENOVO", label: "Lenovo", icon: SiLenovo }, { value: "LENOVO", label: "Lenovo", icon: SiLenovo },
{ value: "OTHER", label: "Other", icon: LucideCircleEllipsis }, { value: "OTHER", label: "Other", icon: LucideCircleEllipsis },
]; ];
export const OSItems = [ const OSItems = [
{ value: "WINDOWS", label: "Windows", icon: FaWindows }, { value: "WINDOWS", label: "Windows", icon: FaWindows },
{ value: "MACOS", label: "Macos", icon: SiMacos }, { value: "MACOS", label: "Macos", icon: SiMacos },
{ value: "LINUX", label: "Linux", icon: SiLinux }, { value: "LINUX", label: "Linux", icon: SiLinux },
{ value: "OTHER", label: "Other", icon: LucideCircleEllipsis }, { value: "OTHER", label: "Other", icon: LucideCircleEllipsis },
]; ];
export const reviewDatas = [ const reviewDatas = [
{ {
productId: 2, productId: 2,
modelId: 1, modelId: 1,
@ -79,3 +78,87 @@ export const reviewDatas = [
confidenceScore: 0.88, confidenceScore: 0.88,
}, },
]; ];
const scrapePath = "/api/scrape";
const backendUrl = process.env.BACKEND_URL || "http://localhost:8000";
const aiRecommendPath = `${backendUrl}/recommend`;
const userMetricPath = "/api/user-metric";
const profilePath = "/api/profile";
const chromiumUrl =
"https://github.com/Sparticuz/chromium/releases/download/v131.0.1/chromium-v131.0.1-pack.tar";
const sentimentStatsPath = "/api/review/sentiment-stats";
const productPath = "/api/product";
const reviewPath = "/api/review";
const positiveWords = [
"bagus",
"cepat",
"aman",
"baik",
"mulus",
"moga",
"awet",
"mantap",
"sangat",
"fungsi",
];
const negativeWords = [
"lebih",
"jual",
"baru",
"lalu",
"tahun",
"masalah",
"rusak",
"garansi",
"layar",
"kecewa",
];
const models = [
{
code: "none",
value: "xgboost",
label: "XGBoost (Baseline)",
desc: "Model 1",
},
{
code: "Grid Search",
value: "xgboost",
label: "XGBoost (Tuned)",
desc: "Model 2",
},
{
code: "recommended",
value: "xgboost",
label: "XGBoost (Fully Optimized)",
desc: "Model 3",
},
];
const predictPath = `${backendUrl}/predict`;
const socketPath = "/api/socket";
const wordCloudPath = "/api/word-cloud";
export {
scrapePath,
aiRecommendPath,
userMetricPath,
profilePath,
chromiumUrl,
sentimentStatsPath,
productPath,
reviewPath,
positiveWords,
negativeWords,
models,
predictPath,
MODEL_OPTIONS,
WORD_LIMIT,
professionItems,
brandItems,
OSItems,
reviewDatas,
socketPath,
wordCloudPath,
};

View File

@ -1,5 +1,6 @@
import { useReviewTable } from "../hooks/useReviewTable"; import { Minus, ThumbsDown, ThumbsUp } from "lucide-react";
import { import {
AnalysisResult,
RadarProps, RadarProps,
ScrapeResult, ScrapeResult,
VisiblePageProps, VisiblePageProps,
@ -142,3 +143,30 @@ export const radarFormat = ({ data }: RadarProps) => {
return { chartData, colors }; return { chartData, colors };
}; };
export const configDisplay = (sentiment: AnalysisResult["sentiment"]) => {
const config = {
POSITIVE: {
icon: ThumbsUp,
label: "Positif",
bgClass: "bg-sentiment-positive-light",
textClass: "text-sentiment-positive",
borderClass: "border-sentiment-positive/30",
},
NEGATIVE: {
icon: ThumbsDown,
label: "Negatif",
bgClass: "bg-sentiment-negative-light",
textClass: "text-sentiment-negative",
borderClass: "border-sentiment-negative/30",
},
NEUTRAL: {
icon: Minus,
label: "Netral",
bgClass: "bg-sentiment-neutral-light",
textClass: "text-sentiment-neutral",
borderClass: "border-sentiment-neutral/30",
},
};
return config[sentiment];
};

View File

@ -12,5 +12,4 @@ const iconStyles = {
neutral: "bg-sentiment-neutral/10 text-sentiment-neutral", neutral: "bg-sentiment-neutral/10 text-sentiment-neutral",
}; };
export { variantStyles, iconStyles }; export { variantStyles, iconStyles };