From 1317694bcc98afb1494e6b1e68a57d9a2e66777e Mon Sep 17 00:00:00 2001 From: Mahen Date: Thu, 19 Feb 2026 10:53:31 +0700 Subject: [PATCH] perf: use RHF for better form state management --- src/app/profile/lib/action.ts | 1 + src/app/validation/analyze.schema.ts | 31 ++ src/components/dashboards/AnalysisClient.tsx | 324 +++++++++++------- src/components/dashboards/DashboardClient.tsx | 4 +- src/hooks/useAnalyzeText.ts | 126 +++---- src/services/analyze.service.ts | 33 ++ 6 files changed, 341 insertions(+), 178 deletions(-) create mode 100644 src/app/validation/analyze.schema.ts create mode 100644 src/services/analyze.service.ts diff --git a/src/app/profile/lib/action.ts b/src/app/profile/lib/action.ts index 0728cf5..76f0c63 100644 --- a/src/app/profile/lib/action.ts +++ b/src/app/profile/lib/action.ts @@ -1,3 +1,4 @@ +"use server"; import { getServerSession } from "next-auth/next"; import { authOptions } from "../../api/auth/[...nextauth]/route"; import prisma from "@/lib/prisma"; diff --git a/src/app/validation/analyze.schema.ts b/src/app/validation/analyze.schema.ts new file mode 100644 index 0000000..31750f4 --- /dev/null +++ b/src/app/validation/analyze.schema.ts @@ -0,0 +1,31 @@ +import z from "zod"; + +// const brandEnum = z.enum([ +// "APPLE", +// "ASUS", +// "ACER", +// "LENOVO", +// "HP", +// "DELL", +// "MSI", +// "AXIOO", +// "ADVAN", +// "ZYREX", +// "OTHER", +// ]); + +const professionEnum = z.enum([ + "PROGRAMMER", + "STUDENT", + "GAMER", + "DESIGNER", + "OTHER", +]); + +export const analyzeSchema = z.object({ + profession: professionEnum, +// brands: brandEnum, + url1: z.string().min(10, "Tautan 1 minimal 10 karakter"), + url2: z.string().min(10, "Tautan 2 minimal 10 karakter"), + url3: z.string().optional().or(z.literal("")), +}); diff --git a/src/components/dashboards/AnalysisClient.tsx b/src/components/dashboards/AnalysisClient.tsx index 9693819..c16780d 100644 --- a/src/components/dashboards/AnalysisClient.tsx +++ b/src/components/dashboards/AnalysisClient.tsx @@ -13,150 +13,242 @@ import { Input } from "../ui/input"; import { Button } from "../ui/button"; import ResultSection from "./ResultSection"; import { professionItems } from "@/src/utils/const"; +import { Controller } from "react-hook-form"; export default function AnalysisClient() { const { - url1, - url2, - url3, - profession, + control, + register, + handleSubmit, + onSubmit, + setValue, + errors, + isValid, loading, result, - disabled, showField, - handleAnalyze, - setProfession, - setUrl1, - setUrl2, - setDisabled, - setUrl3, setShowField, } = useAnalyseText(); return (
-
+

Analisis Sentimen Real-time

-
-
- - -
-
- - setUrl1(e.target.value)} - className="border rounded-md focus:ring-2 focus:ring-primary" - required - /> -
-
- -
-
- - setUrl2(e.target.value)} - className="border rounded-md focus:ring-2 focus:ring-primary w-full" - required - /> -
- - {showField ? ( -
+
+
+ {/* Field Profesi */} +
-
- setUrl3(e.target.value)} - className="border p-2 rounded-md focus:ring-2 focus:ring-primary w-full" - required - /> + ( + + )} + /> + {errors.profession && ( +

+ {errors.profession.message} +

+ )} +
+ +
+ + + {errors.url1 && ( +

+ {errors.url1.message} +

+ )} +
+ + {/* Field Merek Laptop (Sekarang bernama preferredBrand) */} + {/*
+ + ( + + )} + /> + {errors.brands && ( +

+ {errors.brands.message} +

+ )} +
*/} +
+ +
+
+ + + {errors.url2 && ( +

+ {errors.url2.message} +

+ )} +
+ + {/* {showField ? ( +
+ +
+
+ + {errors.url3 && ( +

+ {errors.url3.message} +

+ )} +
+ +
+
+ ) : ( +
-
- ) : ( -
- -
- )} + )} */} + + {showField ? ( +
+ +
+ + {errors.url3 && ( +

+ {errors.url3.message} +

+ )} + +
+
+ ) : ( +
+ +
+ )} +
+ + {/*
+ +
*/}
-
+
diff --git a/src/components/dashboards/DashboardClient.tsx b/src/components/dashboards/DashboardClient.tsx index e3e7dd3..3a32c7c 100644 --- a/src/components/dashboards/DashboardClient.tsx +++ b/src/components/dashboards/DashboardClient.tsx @@ -3,12 +3,10 @@ import { Header } from "./Header"; import { Frown, Meh, MessageSquareText, Smile, TrendingUp } from "lucide-react"; import { StatCard } from "./StatCard"; -import { sentimentDistribution, trendData } from "@/src/app/dashboard/lib/data"; import { ModelInfoSkeleton } from "../skeletons/ModelInfoSkeleton"; import { ModelInfo } from "./ModelInfo"; import { BrandFilter } from "./BrandFilter"; import { ReviewTable } from "./ReviewTable"; -import { SentimentChart, TrendChart } from "@/src/utils/dImports"; import { useDashboards } from "@/src/hooks/useDashboard"; import { WordCloud } from "./WordCloud"; import AnalysisClient from "./AnalysisClient"; @@ -26,7 +24,7 @@ export default function DashboardClient() { } = useDashboards(); return ( -
+
diff --git a/src/hooks/useAnalyzeText.ts b/src/hooks/useAnalyzeText.ts index 9396363..178ef50 100644 --- a/src/hooks/useAnalyzeText.ts +++ b/src/hooks/useAnalyzeText.ts @@ -1,24 +1,68 @@ -import { useState } from "react"; +import { useEffect, useState } from "react"; import { useSession } from "next-auth/react"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; import { AnalysisResults } from "../types"; +import { + scrapeProduct, + getAIRecommendation, +} from "../services/analyze.service"; +import { analyzeSchema } from "../app/validation/analyze.schema"; // Sesuaikan path-nya +import { getAnotherUserData } from "../app/profile/lib/action"; + +export type AnalyzeFormData = z.infer; export const useAnalyseText = () => { const { data: session } = useSession(); - 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(null); - const [disabled, setDisabled] = useState(false); const [showField, setShowField] = useState(false); - const handleAnalyze = async () => { + const { + control, + register, + handleSubmit, + setValue, + formState: { errors, isValid }, + } = useForm({ + resolver: zodResolver(analyzeSchema), + mode: "onChange", + defaultValues: { + url1: "", + url2: "", + url3: "", + }, + }); + + useEffect(() => { + const fetchProfession = async () => { + try { + const user = await getAnotherUserData(); + + const userProfession = + user?.preference?.profession || user?.preference?.profession; + + if (userProfession) { + setValue("profession", userProfession, { + shouldValidate: true, + shouldDirty: true, + }); + } + } catch (error) { + console.error("Gagal mengambil data profesi user:", error); + } + }; + + if (session?.user) { + fetchProfession(); + } + }, [session, setValue]); + + const onSubmit = async (data: AnalyzeFormData) => { if (!session?.user?.email) { - alert( - "Anda harus login terlebih dahulu untuk menyimpan riwayat analisis.", - ); + alert("Anda harus login terlebih dahulu."); return; } @@ -26,57 +70,25 @@ export const useAnalyseText = () => { setResult(null); try { - const urlsToScrape = [url1, url2, url3].filter( + const urlsToScrape = [data.url1, data.url2, data.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) => { - if (!res.ok) throw new Error(`Gagal scraping: ${u}`); - return res.json(); - }), - ); + ) as string[]; + const scrapePromises = urlsToScrape.map((url) => scrapeProduct(url)); const scrapeResults = await Promise.all(scrapePromises); - for (const res of scrapeResults) { - if (!res.success) - throw new Error("Gagal mengambil data dari salah satu tautan."); - } - const candidates = scrapeResults.map((res) => ({ name: res.data.name, url: res.data.url, reviews: res.data.reviews, })); - const payload = { + const aiResult = await getAIRecommendation({ user_email: session.user.email, - profession: profession, - candidates: candidates, - }; - - const aiRes = await fetch("http://localhost:8000/recommend", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(payload), + profession: data.profession, + candidates, }); - if (!aiRes.ok) throw new Error("Gagal melakukan analisis AI"); - - const aiResult = await aiRes.json(); setResult(aiResult); } catch (error: any) { alert("Terjadi kesalahan: " + error.message); @@ -86,20 +98,16 @@ export const useAnalyseText = () => { }; return { - url1, - url2, - url3, - profession, + control, + register, + handleSubmit, + setValue, + onSubmit, + errors, + isValid, loading, result, - disabled, showField, - handleAnalyze, - setProfession, - setUrl1, - setUrl2, - setUrl3, - setDisabled, setShowField, }; }; diff --git a/src/services/analyze.service.ts b/src/services/analyze.service.ts new file mode 100644 index 0000000..c5b2c5e --- /dev/null +++ b/src/services/analyze.service.ts @@ -0,0 +1,33 @@ +export const scrapeProduct = async (url: string) => { + const res = await fetch("/api/scrape", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ url }), + }); + + if (!res.ok) throw new Error(`Gagal scraping: ${url}`); + const data = await res.json(); + + if (!data.success) + throw new Error(`Gagal mengambil data dari tautan: ${url}`); + + return data; +}; + +export const getAIRecommendation = async (payload: { + user_email: string; + profession: string; + candidates: { name: string; url: string; reviews: any[] }[]; +}) => { + const aiRes = await fetch("http://localhost:8000/recommend", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + + if (!aiRes.ok) throw new Error("Gagal melakukan analisis AI"); + + return await aiRes.json(); +};