perf: use RHF for better form state management

This commit is contained in:
Mahen 2026-02-19 10:53:31 +07:00
parent 1fcc44787b
commit 1317694bcc
6 changed files with 341 additions and 178 deletions

View File

@ -1,3 +1,4 @@
"use server";
import { getServerSession } from "next-auth/next"; import { getServerSession } from "next-auth/next";
import { authOptions } from "../../api/auth/[...nextauth]/route"; import { authOptions } from "../../api/auth/[...nextauth]/route";
import prisma from "@/lib/prisma"; import prisma from "@/lib/prisma";

View File

@ -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("")),
});

View File

@ -13,150 +13,242 @@ import { Input } from "../ui/input";
import { Button } from "../ui/button"; import { Button } from "../ui/button";
import ResultSection from "./ResultSection"; import ResultSection from "./ResultSection";
import { professionItems } from "@/src/utils/const"; import { professionItems } from "@/src/utils/const";
import { Controller } from "react-hook-form";
export default function AnalysisClient() { export default function AnalysisClient() {
const { const {
url1, control,
url2, register,
url3, handleSubmit,
profession, onSubmit,
setValue,
errors,
isValid,
loading, loading,
result, result,
disabled,
showField, showField,
handleAnalyze,
setProfession,
setUrl1,
setUrl2,
setDisabled,
setUrl3,
setShowField, setShowField,
} = useAnalyseText(); } = useAnalyseText();
return ( return (
<div className="w-full mx-auto"> <div className="w-full mx-auto">
<div className="bg-white p-6 rounded-lg border mb-8"> <form
onSubmit={handleSubmit(onSubmit)}
className="bg-white p-6 rounded-lg border mb-8"
>
<div className="mb-4 flex items-center gap-2"> <div className="mb-4 flex items-center gap-2">
<Sparkles className="h-5 w-5 text-primary" /> <Sparkles className="h-5 w-5 text-primary" />
<h3 className="text-lg font-semibold">Analisis Sentimen Real-time</h3> <h3 className="text-lg font-semibold">Analisis Sentimen Real-time</h3>
</div> </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 <div className="flex flex-col gap-4">
className="bg-card border-border shadow-lg" <div className="flex w-full gap-4">
position="popper" {/* Field Profesi */}
> <div className="w-1/2">
{professionItems.map((item) => {
const PIcon = item.icon;
return (
<SelectItem
key={item.value}
value={item.value}
className="cursor-pointer hover:bg-primary hover:text-card focus:bg-primary focus:text-card"
>
<div className="flex gap-2 items-center">
<span>
<PIcon className="h-4 w-4 text-muted-foreground" />
</span>
<span>{item.label}</span>
</div>
</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-primary"
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-primary 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"> <label className="block mb-1 text-sm font-medium text-gray-700">
Tautan Produk 3 Pilih Profesi
</label> </label>
<div className="flex gap-2"> <Controller
<Input name="profession"
type="text" control={control}
placeholder="Contoh: https://www.tokopedia.com/..." render={({ field }) => (
value={url3} <Select onValueChange={field.onChange} value={field.value}>
onChange={(e) => setUrl3(e.target.value)} <SelectTrigger
className="border p-2 rounded-md focus:ring-2 focus:ring-primary w-full" className={`w-full ${errors.profession ? "border-red-500" : ""}`}
required >
/> <SelectValue placeholder="Pilih Profesi" />
</SelectTrigger>
<SelectContent className="bg-card" position="popper">
{professionItems.map((item) => (
<SelectItem key={item.value} value={item.value} className="focus:bg-primary focus:text-card">
<div className="flex gap-2 items-center">
<item.icon className="h-4 w-4" />
<span>{item.label}</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
{errors.profession && (
<p className="text-red-500 text-xs mt-1">
{errors.profession.message}
</p>
)}
</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"
className={`${errors.url1 ? "border-red-500 focus:ring-red-500" : "focus:ring-primary"}`}
{...register("url1")}
/>
{errors.url1 && (
<p className="text-red-500 text-xs mt-1">
{errors.url1.message}
</p>
)}
</div>
{/* Field Merek Laptop (Sekarang bernama preferredBrand) */}
{/* <div className="w-1/2">
<label className="block mb-1 text-sm font-medium text-gray-700">
Pilih Merek
</label>
<Controller
name="brands"
control={control}
render={({ field }) => (
<Select onValueChange={field.onChange} value={field.value}>
<SelectTrigger
className={`w-full ${errors.brands ? "border-red-500" : ""}`}
>
<SelectValue placeholder="Pilih Merek Laptop" />
</SelectTrigger>
<SelectContent className="bg-card" position="popper">
{brandItems.map((item) => (
<SelectItem key={item.value} value={item.value}>
<div className="flex gap-2 items-center">
<item.icon className="h-4 w-4" />
<span>{item.label}</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
{errors.brands && (
<p className="text-red-500 text-xs mt-1">
{errors.brands.message}
</p>
)}
</div> */}
</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">
Tautan Produk 2
</label>
<Input
type="text"
placeholder="Contoh: https://www.tokopedia.com/lenovo/thinkpad-x1-carbon"
className={`w-full ${errors.url2 ? "border-red-500 focus:ring-red-500" : "focus:ring-primary"}`}
{...register("url2")}
/>
{errors.url2 && (
<p className="text-red-500 text-xs mt-1">
{errors.url2.message}
</p>
)}
</div>
{/* {showField ? (
<div className="w-1/2 mr-auto animate-in fade-in">
<label className="block mb-1 text-sm font-medium text-gray-700">
Tautan Produk 3
</label>
<div className="flex gap-2">
<div className="w-full flex flex-col">
<Input
type="text"
placeholder="Contoh: https://www.tokopedia.com/lenovo/thinkpad-x1-carbon"
className={`w-full ${errors.url3 ? "border-red-500 focus:ring-red-500" : "focus:ring-primary"}`}
{...register("url3")}
/>
{errors.url3 && (
<p className="text-red-500 text-xs mt-1">
{errors.url3.message}
</p>
)}
</div>
<Button
type="button"
variant="ghost"
onClick={() => {
setShowField(false);
setValue("url3", "");
}}
className="text-red-500"
>
</Button>
</div>
</div>
) : (
<div className="w-max mt-4">
<Button <Button
type="button" type="button"
variant="ghost" onClick={() => setShowField(true)}
onClick={() => { variant="outline"
setShowField(false);
setUrl3("");
}}
className="text-red-500 hover:text-red-700 hover:bg-red-50 shrink-0"
> >
+ Tambah Tautan Produk Lainnya
</Button> </Button>
</div> </div>
</div> )} */}
) : (
<div className="w-1/2 "> {showField ? (
<Button <div className="w-1/2 animate-in fade-in slide-in-from-bottom-2 duration-300">
type="button" <label className="block mb-1 text-sm font-medium text-gray-700">
onClick={() => setShowField(true)} Tautan Produk 3
className="w-full bg-[#F8FBFF] text-primary hover:text-white border-dashed border border-primary/20 hover:bg-primary transition-all" </label>
> <div className="flex gap-2">
+ Tambah Tautan Produk Lainnya <Input
</Button> type="text"
</div> placeholder="Contoh: https://www.tokopedia.com/lenovo/thinkpad-x1-carbon"
)} className={`w-full ${errors.url3 ? "border-red-500 focus:ring-red-500" : "focus:ring-primary"}`}
{...register("url3")}
/>
{errors.url3 && (
<p className="text-red-500 text-xs mt-1">
{errors.url3.message}
</p>
)}
<Button
type="button"
variant="ghost"
onClick={() => {
setShowField(false);
}}
className="text-red-500 hover:text-red-700 hover:bg-red-50 shrink-0"
>
</Button>
</div>
</div>
) : (
<div className="w-1/2 items-end flex">
<Button
type="button"
onClick={() => setShowField(true)}
className="w-full bg-card text-primary hover:bg-[#F8FBFF] border-dashed border border-primary/20 transition-all shadow-xs"
>
+ Tambah Tautan Produk Lainnya
</Button>
</div>
)}
</div>
{/* <div className="flex w-full gap-4 justify-center">
</div> */}
</div> </div>
<Button <Button
onClick={handleAnalyze} type="submit"
disabled={!url1 || !url2 || !profession || loading} disabled={!isValid || loading}
className={`bg-primary cursor-pointer text-white px-6 py-3 mt-6 rounded-md w-max transition-colors disabled:bg-gray-400`} className="bg-primary text-white px-6 py-3 mt-6 rounded-md transition-colors disabled:bg-gray-400"
> >
<Sparkles className="h-4 w-4 text-white" /> <Sparkles className="h-4 w-4 mr-2" />
{loading {loading ? "Menganalisis..." : "Bandingkan Sekarang"}
? "Sedang Mengambil Ulasan & Menganalisis..."
: "Bandingkan Sekarang"}
</Button> </Button>
</div> </form>
<ResultSection result={result} /> <ResultSection result={result} />
</div> </div>

View File

@ -3,12 +3,10 @@
import { Header } from "./Header"; import { Header } from "./Header";
import { Frown, Meh, MessageSquareText, Smile, TrendingUp } from "lucide-react"; import { Frown, Meh, MessageSquareText, Smile, TrendingUp } from "lucide-react";
import { StatCard } from "./StatCard"; import { StatCard } from "./StatCard";
import { sentimentDistribution, trendData } from "@/src/app/dashboard/lib/data";
import { ModelInfoSkeleton } from "../skeletons/ModelInfoSkeleton"; import { ModelInfoSkeleton } from "../skeletons/ModelInfoSkeleton";
import { ModelInfo } from "./ModelInfo"; import { ModelInfo } from "./ModelInfo";
import { BrandFilter } from "./BrandFilter"; import { BrandFilter } from "./BrandFilter";
import { ReviewTable } from "./ReviewTable"; import { ReviewTable } from "./ReviewTable";
import { SentimentChart, TrendChart } from "@/src/utils/dImports";
import { useDashboards } from "@/src/hooks/useDashboard"; import { useDashboards } from "@/src/hooks/useDashboard";
import { WordCloud } from "./WordCloud"; import { WordCloud } from "./WordCloud";
import AnalysisClient from "./AnalysisClient"; import AnalysisClient from "./AnalysisClient";
@ -26,7 +24,7 @@ export default function DashboardClient() {
} = useDashboards(); } = useDashboards();
return ( return (
<div className="min-h-screen bg-[#F8FBFF]"> <div className="min-h-screen bg-[#F8FBFF]" suppressHydrationWarning>
<Header /> <Header />
<main className="container mx-auto px-4 py-8"> <main className="container mx-auto px-4 py-8">

View File

@ -1,24 +1,68 @@
import { useState } from "react"; import { useEffect, useState } from "react";
import { useSession } from "next-auth/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 { 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<typeof analyzeSchema>;
export const useAnalyseText = () => { export const useAnalyseText = () => {
const { data: session } = useSession(); 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 [loading, setLoading] = useState(false);
const [result, setResult] = useState<AnalysisResults | null>(null); const [result, setResult] = useState<AnalysisResults | null>(null);
const [disabled, setDisabled] = useState(false);
const [showField, setShowField] = useState(false); const [showField, setShowField] = useState(false);
const handleAnalyze = async () => { const {
control,
register,
handleSubmit,
setValue,
formState: { errors, isValid },
} = useForm<AnalyzeFormData>({
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) { if (!session?.user?.email) {
alert( alert("Anda harus login terlebih dahulu.");
"Anda harus login terlebih dahulu untuk menyimpan riwayat analisis.",
);
return; return;
} }
@ -26,57 +70,25 @@ export const useAnalyseText = () => {
setResult(null); setResult(null);
try { try {
const urlsToScrape = [url1, url2, url3].filter( const urlsToScrape = [data.url1, data.url2, data.url3].filter(
(url) => url && url.trim() !== "", (url) => url && url.trim() !== "",
); ) as string[];
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();
}),
);
const scrapePromises = urlsToScrape.map((url) => scrapeProduct(url));
const scrapeResults = await Promise.all(scrapePromises); 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) => ({ const candidates = scrapeResults.map((res) => ({
name: res.data.name, name: res.data.name,
url: res.data.url, url: res.data.url,
reviews: res.data.reviews, reviews: res.data.reviews,
})); }));
const payload = { const aiResult = await getAIRecommendation({
user_email: session.user.email, user_email: session.user.email,
profession: profession, profession: data.profession,
candidates: candidates, candidates,
};
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");
const aiResult = await aiRes.json();
setResult(aiResult); setResult(aiResult);
} catch (error: any) { } catch (error: any) {
alert("Terjadi kesalahan: " + error.message); alert("Terjadi kesalahan: " + error.message);
@ -86,20 +98,16 @@ export const useAnalyseText = () => {
}; };
return { return {
url1, control,
url2, register,
url3, handleSubmit,
profession, setValue,
onSubmit,
errors,
isValid,
loading, loading,
result, result,
disabled,
showField, showField,
handleAnalyze,
setProfession,
setUrl1,
setUrl2,
setUrl3,
setDisabled,
setShowField, setShowField,
}; };
}; };

View File

@ -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();
};