style: improve profile card UI

This commit is contained in:
Mahen 2026-02-14 10:27:50 +07:00
parent 641bc5bfcf
commit da42cd48ac
22 changed files with 318 additions and 391 deletions

View File

@ -0,0 +1,8 @@
/*
Warnings:
- You are about to drop the column `version` on the `Model` table. All the data in the column will be lost.
*/
-- AlterTable
ALTER TABLE "Model" DROP COLUMN "version";

View File

@ -166,7 +166,6 @@ model Model {
id Int @id @default(autoincrement())
modelName String
description String?
version String?
accuracy Float
macroF1 Float

View File

@ -8,14 +8,26 @@ export const getAnotherUserData = async () => {
if (!session?.user?.email) return null;
const user = await prisma.user.findUnique({
where: { email: session.user.email },
const userData = await prisma.user.findUnique({
where: {
email: session.user.email,
},
select: {
gender: true,
bio: true,
preference: {
select: {
id: true,
profession: true,
preferedBrand: true,
preferredOS: true,
budgetMin: true,
budgetMax: true,
},
},
},
});
return user;
return userData;
} catch (error) {
console.error("Error fetching user data:", error);
return null;

View File

@ -1,18 +1,11 @@
import { Header } from "@/src/components/dashboards/Header";
import { getAnotherUserData } from "./lib/action";
import ProfileClient from "@/src/components/dashboards/ProfileClient";
import { UserGender } from "@prisma/client";
export default async function ProfilePage() {
const user = await getAnotherUserData();
return (
<>
<div className="min-h-screen bg-[#F8FBFF]">
<Header />
<ProfileClient
gender={user?.gender as UserGender}
productReference={user?.productReference || "None"}
/>
</>
<ProfileClient />
</div>
);
}

View File

@ -12,7 +12,7 @@ import {
import { Input } from "../ui/input";
import { Button } from "../ui/button";
import ResultSection from "./ResultSection";
import { professions } from "@/src/utils/datas";
import { professions } from "@/src/utils/const";
export default function AnalysisClient() {
const {
@ -49,18 +49,30 @@ export default function AnalysisClient() {
<SelectTrigger
className={`w-full mb-6 ${!profession ? "text-gray-500" : "text-black"}`}
>
<SelectValue placeholder="-- Pilih Profesi/Kebutuhan --" />
<SelectValue placeholder="Pilih Profesi/Kebutuhan" />
</SelectTrigger>
<SelectContent
className="bg-card border-border shadow-lg"
position="popper"
>
{professions.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
</SelectItem>
))}
{professions.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>

View File

@ -1,11 +1,6 @@
import { CustomTooltipProps } from "@/src/types";
import React from "react";
const CustomTooltip: React.FC<CustomTooltipProps> = ({
active,
payload,
total,
}) => {
const CustomTooltip = ({ active, payload, total }: CustomTooltipProps) => {
if (active && payload && payload.length) {
const item = payload[0].payload;
const percentage = ((item.value / total) * 100).toFixed(1);

View File

@ -14,11 +14,9 @@ import { BrandFilter } from "./BrandFilter";
import { ReviewTable } from "./ReviewTable";
import { SentimentChart, TrendChart } from "@/src/utils/dImports";
import { useDashboards } from "@/src/hooks/useDashboard";
import SentimentForm from "./SentimentAnalyzer";
import { WordCloud } from "./WordCloud";
import AnalysisPage from "@/src/app/analyze/page";
import SentimentAnalyzer from "./SentimentAnalyzer";
import AnalysisClient from "./AnalysisClient";
import Footer from "./Footer";
export default function DashboardClient() {
const {
@ -133,7 +131,6 @@ export default function DashboardClient() {
</div>
<div className="mb-8">
{/* <SentimentAnalyzer /> */}
<AnalysisClient />
</div>
@ -154,20 +151,7 @@ export default function DashboardClient() {
<ReviewTable />
</div>
<footer className="mt-12 border-t pt-8">
<div className="flex flex-col items-center justify-between gap-4 text-sm text-muted-foreground sm:flex-row">
<div>
<p className="font-medium text-foreground text-center lg:text-start md:text-start">
SentiLaptop - Analisis Sentimen
</p>
<p>Skripsi oleh Syafrizal Wd Mahendra (E41222719)</p>
</div>
<div className="text-center lg:text-end md:text-end">
<p>Politeknik Negeri Jember</p>
<p>PSDKU Teknik Informatika Kampus 3 Nganjuk</p>
</div>
</div>
</footer>
<Footer />
</main>
</div>
);

View File

@ -0,0 +1,18 @@
export default function Footer() {
return (
<footer className="mt-12 border-t pt-8">
<div className="flex flex-col items-center justify-between gap-4 text-sm text-muted-foreground sm:flex-row">
<div>
<p className="font-medium text-foreground text-center lg:text-start md:text-start">
SentiLaptop - Analisis Sentimen
</p>
<p>Skripsi oleh Syafrizal Wd Mahendra (E41222719)</p>
</div>
<div className="text-center lg:text-end md:text-end">
<p>Politeknik Negeri Jember</p>
<p>PSDKU Teknik Informatika Kampus 3 Nganjuk</p>
</div>
</div>
</footer>
);
}

View File

@ -17,7 +17,7 @@ export function ModelInfo({ data }: { data: ModelDB[] }) {
if (!data || data.length === 0) {
return (
<div className="rounded-xl border bg-card p-6 flex items-center justify-center h-[350px]">
<div className="rounded-xl border bg-card p-6 flex items-center justify-center h-87.5">
<p className="text-muted-foreground text-sm">
Data model tidak tersedia.
</p>

View File

@ -0,0 +1,166 @@
"use client";
import { motion } from "framer-motion";
import Image from "next/image";
import { Pencil, Briefcase, Wallet, Laptop, User, Monitor, Fan } from "lucide-react";
import { ProfileClientProps } from "@/src/types";
import { useSession } from "next-auth/react";
import { Button } from "../ui/button";
import { Separator } from "../ui/separator";
import { brandFormat, formatRupiah } from "@/src/utils/datas";
export default function ProfileCard({
bio,
preferenceBrand,
preferenceOS,
budgetMax,
budgetMin,
profession,
}: ProfileClientProps) {
const session = useSession();
const { brands } = brandFormat({ preferenceBrand });
return (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, ease: "circOut" }}
className="mx-auto w-full max-w-xl overflow-hidden rounded-2xl border bg-card mt-4"
>
<div className="relative bg-linier-to-r from-primary/5 via-primary/10 to-transparent p-6 sm:p-8">
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
<div className="flex items-center gap-5">
<div className="relative">
<Image
src={session?.data?.user?.image ?? "/default-avatar.svg"}
alt="User Avatar"
width={88}
height={88}
className="h-20 w-20 rounded-full border-4 border-background object-cover shadow-sm"
/>
<div className="absolute bottom-1 right-1 h-4 w-4 rounded-full border-2 border-background bg-sentiment-positive"></div>
</div>
<div>
<h1 className="text-2xl font-bold text-card-foreground tracking-tight">
{session?.data?.user?.name || "Guest User"}
</h1>
<p className="text-sm font-medium text-muted-foreground mb-2">
{session?.data?.user?.email || "Belum ada email"}
</p>
{profession && (
<span className="inline-flex items-center gap-1.5 rounded-full bg-primary/10 px-2.5 py-0.5 text-xs font-semibold text-primary">
<Fan className="w-3.5 h-3.5" />
<span className="capitalize">{profession}</span>
</span>
)}
</div>
</div>
<Button
size="sm"
className="w-full sm:w-auto gap-2 rounded-full shadow-sm"
>
<Pencil className="h-4 w-4" />
Edit Profile
</Button>
</div>
</div>
<Separator />
<div className="p-6 sm:p-8 space-y-8">
<div className="space-y-3">
<div className="flex items-center gap-2 text-sm font-semibold text-muted-foreground uppercase tracking-wider">
<User className="w-4 h-4" />
Tentang Saya
</div>
<div className="rounded-xl bg-muted/50 p-4 border border-muted">
<p className="text-sm leading-relaxed text-foreground/90 italic">
{bio
? `"${bio}"`
: "Belum ada deskripsi profil. Ceritakan sedikit tentang aktivitas dan kebutuhan laptop Anda."}
</p>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-5 rounded-xl border p-5">
<div>
<div className="flex items-center gap-2 text-sm font-semibold text-muted-foreground mb-3">
<Laptop className="w-4 h-4 text-primary" />
Preferensi Merek
</div>
<div className="flex flex-wrap gap-2">
{brands.length > 0 ? (
brands.map((brand, i) => (
<span
key={i}
className="rounded-md bg-secondary px-2.5 py-1 text-xs font-semibold text-secondary-foreground border"
>
{brand}
</span>
))
) : (
<span className="text-sm text-muted-foreground">
Tidak ada preferensi
</span>
)}
</div>
</div>
<Separator />
<div>
<div className="flex items-center gap-2 text-sm font-semibold text-muted-foreground mb-3">
<Monitor className="w-4 h-4 text-primary" />
Sistem Operasi
</div>
<div>
{preferenceOS ? (
<span className="inline-flex items-center rounded-md bg-[#F8FBFF] px-2.5 py-1 text-xs font-bold text-primary border border-primary">
{preferenceOS}
</span>
) : (
<span className="text-sm text-muted-foreground">
Bebas / Semua OS
</span>
)}
</div>
</div>
</div>
<div className="space-y-3 rounded-xl border bg-linier-to-br from-green-50 to-emerald-50/30 p-5">
<div className="flex items-center gap-2 text-sm font-semibold">
<Wallet className="w-4 h-4" />
Rentang Anggaran
</div>
{budgetMin || budgetMax ? (
<div className="flex flex-col justify-center h-[calc(100%-2rem)]">
<p className="text-sm text-muted-foreground mb-1">Dari</p>
<p className="text-xl font-bold">
{formatRupiah(budgetMin)}
</p>
<div className="my-2 h-px w-full bg-border"></div>
<p className="text-sm text-muted-foreground mb-1">Hingga</p>
<p className="text-xl font-bold text-sentiment-positive">
{formatRupiah(budgetMax)}
</p>
</div>
) : (
<div className="flex h-[calc(100%-2rem)] items-center">
<p className="text-sm text-muted-foreground">
Budget belum diatur.
</p>
</div>
)}
</div>
</div>
</div>
</motion.div>
);
}

View File

@ -1,27 +1,13 @@
"use client";
import Image from "next/image";
import { Button } from "../ui/button";
import { ArrowLeft, Pencil } from "lucide-react";
import { Separator } from "../ui/separator";
import { useSession } from "next-auth/react";
import { ArrowLeft } from "lucide-react";
import Link from "next/link";
import { motion } from "framer-motion";
import { ProfileClientProps } from "@/src/types";
import ProfileCard from "./ProfileCard";
import { getAnotherUserData } from "@/src/app/profile/lib/action";
export default function ProfileClient({
gender,
productReference,
}: ProfileClientProps) {
const session = useSession();
export default async function ProfileClient() {
const user = await getAnotherUserData();
return (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
transition={{ duration: 0.4, ease: "easeInOut" }}
className="container mx-auto px-4 py-8"
>
<div className="container mx-auto px-4 py-8">
<div className="flex max-w-xl mx-auto">
<Link
href="/"
@ -32,55 +18,14 @@ export default function ProfileClient({
</Link>
</div>
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.4, ease: "easeOut" }}
className="mx-auto w-full max-w-xl rounded-xl border bg-background shadow-sm mt-4"
>
<div className="flex items-center justify-between gap-4 p-6">
<div className="flex items-center gap-4">
<Image
src={session?.data?.user?.image ?? "file.svg"}
alt="User Avatar"
width={80}
height={80}
className="h-14 w-14 rounded-full border object-cover"
/>
<div>
<h1 className="text-lg font-semibold leading-tight">
{session?.data?.user?.name || "Guest"}
</h1>
<p className="text-sm text-muted-foreground">
{session?.data?.user?.email || "Not logged in"}
</p>
</div>
</div>
<Button
variant="outline"
size="sm"
className="gap-2 bg-primary text-card border-none"
>
<Pencil className="h-4 w-4" />
Edit Profile
</Button>
</div>
<Separator />
<div className="grid grid-cols-1 gap-4 p-6 sm:grid-cols-2">
<div className="space-y-1">
<p className="text-sm text-muted-foreground">Gender</p>
<p className="font-medium">{gender || "Not specified"}</p>
</div>
<div className="space-y-1">
<p className="text-sm text-muted-foreground">Product Preference</p>
<p className="font-medium">{productReference || "None"}</p>
</div>
</div>
</motion.div>
</motion.div>
<ProfileCard
bio={user?.bio || "None"}
preferenceBrand={user?.preference?.preferedBrand || "None"}
preferenceOS={user?.preference?.preferredOS || "None"}
budgetMax={user?.preference?.budgetMax || 0}
budgetMin={user?.preference?.budgetMin || 0}
profession={user?.preference?.profession || "None"}
/>
</div>
);
}

View File

@ -1,4 +1,4 @@
import { AnalysisResults, ResultProps } from "@/src/types";
import { ResultProps } from "@/src/types";
import { motion } from "framer-motion";
import { Trophy, ExternalLink, CheckCircle2, TrendingUp } from "lucide-react";
@ -24,7 +24,6 @@ export default function ResultSection({ result }: ResultProps) {
},
}}
>
{/* ================= HEADER WINNER ================= */}
<motion.div
variants={{
hidden: { opacity: 0, y: -20, height: 0 },
@ -37,7 +36,6 @@ export default function ResultSection({ result }: ResultProps) {
}}
className="bg-sentiment-positive text-card p-8 rounded-2xl text-center mb-12 relative overflow-hidden"
>
{/* Dekorasi Background Abstrak */}
<div className="absolute top-0 right-0 -mt-10 -mr-10 w-64 h-64 bg-white/10 rounded-full blur-3xl"></div>
<div className="absolute bottom-0 left-0 -mb-10 -ml-10 w-40 h-40 bg-primary/20 rounded-full blur-3xl"></div>
@ -62,7 +60,6 @@ export default function ResultSection({ result }: ResultProps) {
</div>
</motion.div>
{/* ================= CARDS GRID ================= */}
<div className={`grid gap-8 ${getGridClass(result.details.length)}`}>
{result.details.map((item, index) => {
const isWinner = item.name === result.winning_product;
@ -84,7 +81,6 @@ export default function ResultSection({ result }: ResultProps) {
: "bg-white border-gray-200 hover:border-primary/50 hover:shadow-lg"
}`}
>
{/* WINNER BADGE (Floating) */}
{isWinner && (
<div className="absolute -top-4 left-1/2 transform -translate-x-1/2 bg-sentiment-positive text-white px-6 py-1.5 rounded-full shadow-lg flex items-center gap-2 z-20">
<Trophy className="w-4 h-4 fill-yellow-400 text-yellow-400" />
@ -95,7 +91,6 @@ export default function ResultSection({ result }: ResultProps) {
)}
<div className="p-6 md:p-8 flex flex-col h-full">
{/* 1. HEADER KARTU */}
<div className="mb-6">
<div className="flex justify-between items-start gap-4">
<h3
@ -127,9 +122,7 @@ export default function ResultSection({ result }: ResultProps) {
</a>
</div>
{/* 2. STATISTIK UTAMA (Progress Bars) */}
<div className="space-y-5 mb-8">
{/* Kecocokan Profesi */}
<div>
<div className="flex justify-between items-end mb-2">
<span className="text-sm font-medium text-gray-600 flex items-center gap-1.5">
@ -158,7 +151,6 @@ export default function ResultSection({ result }: ResultProps) {
</div>
</div>
{/* Sentimen Publik */}
<div>
<div className="flex justify-between items-end mb-2">
<span className="text-sm font-medium text-gray-600 flex items-center gap-1.5">
@ -182,7 +174,6 @@ export default function ResultSection({ result }: ResultProps) {
</div>
</div>
{/* 3. FOOTER (Keywords) */}
<div className="mt-auto pt-5 border-t border-gray-100">
<p className="text-xs font-semibold text-gray-400 uppercase tracking-wider mb-3">
Kata Kunci

View File

@ -1,212 +0,0 @@
"use client";
import { Send, Loader2, AlertCircle, Sparkles } from "lucide-react";
import { motion, AnimatePresence } from "framer-motion";
import { cn } from "@/lib/utils";
import {
Combobox,
ComboboxContent,
ComboboxEmpty,
ComboboxInput,
ComboboxItem,
ComboboxList,
} from "../ui/combobox";
import { Item, ItemContent, ItemDescription, ItemTitle } from "../ui/item";
import { Input } from "../ui/input";
import { Textarea } from "../ui/textarea";
import { Button } from "../ui/button";
import { Badge } from "../ui/badge";
import { getSentimentDisplay } from "@/src/utils/datas";
import { useSentimentForm } from "@/src/hooks/useSentimentForm";
export default function SentimentAnalyzer() {
const {
selectedModel,
searchQuery,
laptopName,
text,
isAnalyzing,
result,
filteredItems,
isFormValid,
error,
analyzeText,
setSelectedModel,
setSearchQuery,
setLaptopName,
setText,
} = useSentimentForm();
return (
<div className="rounded-xl border bg-card p-6">
<div className="mb-4 flex items-center gap-2">
<Sparkles className="h-5 w-5 text-primary" />
<h3 className="text-lg font-semibold">Analisis Sentimen Real-time</h3>
</div>
<p className="mb-4 text-sm text-muted-foreground">
Masukkan ulasan produk laptop untuk menganalisis sentimennya menggunakan
model XGBoost
</p>
<form onSubmit={analyzeText}>
<div className="space-y-4">
{error && (
<div className="p-3 text-sm text-red-600 bg-red-50 border border-red-200 rounded-md flex items-center gap-2">
<AlertCircle size={16} />
{error}
</div>
)}
<div className="flex gap-4">
<Combobox
value={selectedModel}
onValueChange={(value) => {
if (value !== null) {
setSelectedModel(value);
}
}}
itemToStringValue={(model) => model?.label ?? ""}
>
<ComboboxInput
placeholder="Pilih model analisis..."
className="focus:ring-primary/20 border-border w-1/2"
onChange={(e) => setSearchQuery(e.target.value)}
/>
<ComboboxContent className="bg-card border-border shadow-lg animate-in fade-in zoom-in-95 duration-200 z-50">
{filteredItems.length === 0 && (
<ComboboxEmpty className="text-muted-foreground py-3 px-4 text-sm text-center">
{` Model "${searchQuery}" tidak ditemukan.`}
</ComboboxEmpty>
)}
<ComboboxList className="p-1">
{filteredItems.map((model) => (
<ComboboxItem
key={model.code}
value={model}
className="rounded-md cursor-pointer transition-colors gap-2 focus:bg-secondary focus:text-primary data-[selected]:bg-secondary data-[selected]:text-primary"
>
<Item size="default" className="p-1 bg-transparent">
<ItemContent>
<ItemTitle className="whitespace-nowrap font-medium text-foreground">
{model.label}
</ItemTitle>
<ItemDescription className="text-muted-foreground/80 text-xs">
{model.desc}
</ItemDescription>
</ItemContent>
</Item>
</ComboboxItem>
))}
</ComboboxList>
</ComboboxContent>
</Combobox>
<Input
className="w-1/2"
placeholder="Masukkan nama laptop (misal: Asus ROG)"
value={laptopName}
onChange={(e) => setLaptopName(e.target.value)}
/>
</div>
<Textarea
placeholder="Tulis ulasan laptop di sini... (Contoh: Baterainya awet tapi kipas berisik)"
value={text}
onChange={(e) => setText(e.target.value)}
rows={4}
className="resize-none"
/>
<Button
type="submit"
disabled={!isFormValid || isAnalyzing}
className="w-full gap-2"
>
{isAnalyzing ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
Sedang Menganalisis...
</>
) : (
<>
<Send className="h-4 w-4" />
Analisis Sentimen
</>
)}
</Button>
<AnimatePresence mode="wait">
{result && (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
transition={{ duration: 0.3 }}
className={cn(
"rounded-lg border p-4",
getSentimentDisplay(result.sentiment).bgClass,
getSentimentDisplay(result.sentiment).borderClass,
)}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
{(() => {
const { icon: Icon, textClass } = getSentimentDisplay(
result.sentiment,
);
return (
<div
className={cn(
"flex h-12 w-12 items-center justify-center rounded-full bg-white/50",
textClass,
)}
>
<Icon className="h-6 w-6" />
</div>
);
})()}
<div>
<p
className={cn(
"text-xl font-bold",
getSentimentDisplay(result.sentiment).textClass,
)}
>
{getSentimentDisplay(result.sentiment).label}
</p>
<p className="text-sm text-muted-foreground">
Tingkat Keyakinan (Confidence):{" "}
{(result.confidence * 100).toFixed(1)}%
</p>
</div>
</div>
</div>
{result.keywords && result.keywords.length > 0 && (
<div className="mt-4 pt-4 border-t border-black/5 dark:border-white/5">
<p className="mb-2 text-sm font-medium text-muted-foreground flex items-center gap-2">
Kata Kunci Terdeteksi:
</p>
<div className="flex flex-wrap gap-2">
{result.keywords.map((keyword, index) => (
<Badge
key={index}
variant="secondary"
className="text-xs px-2 py-1 bg-white/80 dark:bg-black/20 hover:bg-white border-black/10"
>
{keyword}
</Badge>
))}
</div>
</div>
)}
</motion.div>
)}
</AnimatePresence>
</div>
</form>
</div>
);
}

View File

@ -1,5 +1,5 @@
import { cn } from "@/lib/utils";
import { WordCloudItemProps, WordItem } from "@/src/types";
import { WordCloudItemProps } from "@/src/types";
import { setWordCloud } from "@/src/utils/datas";
const WordCloudItem: React.FC<WordCloudItemProps> = ({

View File

@ -13,7 +13,7 @@ export function ModelInfoSkeleton() {
<div className="h-4.5 w-13.5 rounded-full bg-gray-200" />
</div>
<div className="mb-6 min-h-[40px] space-y-2">
<div className="mb-6 min-h-10 space-y-2">
<div className="h-4 w-3/4 rounded bg-gray-100" />
<div className="h-4 w-1/2 rounded bg-gray-100" />
</div>

View File

@ -1,7 +1,7 @@
"use client";
import { useState } from "react";
import { MODEL_OPTIONS } from "../utils/datas";
import { MODEL_OPTIONS } from "../utils/const";
export const useSentimentForm = () => {
const [selectedModel, setSelectedModel] = useState(MODEL_OPTIONS[2]);

View File

@ -1,9 +0,0 @@
// import { useEffect, useState } from "react";
// export const useTrendChart = () => {
// const isMounted = true;
// useEffect(() => {
// setIsMounted(true);
// }, []);
// return { isMounted, setIsMounted };
// };

View File

@ -6,7 +6,7 @@ import {
Sentiment,
WordItem,
} from "@/src/types";
import { WORD_LIMIT } from "../utils/datas";
import { WORD_LIMIT } from "../utils/const";
export const useWordCloud = () => {
const [words, setWords] = useState<WordItem[]>([]);

View File

@ -1,9 +1,9 @@
import { UserGender } from "@prisma/client";
import { LucideIcon } from "lucide-react";
import Brand from "@prisma/client";
export interface ModelDB {
modelName: string;
description: string;
description: string | null;
accuracy: number;
macroF1: number;
f1Negative: number;
@ -11,8 +11,13 @@ export interface ModelDB {
}
export interface ProfileClientProps {
gender?: UserGender;
productReference?: string;
bio?: string;
preferenceBrand?: string;
preferenceOS: string;
budgetMin: number;
budgetMax: number;
profession: string;
id?: number;
}
interface Brand {
@ -62,7 +67,7 @@ export interface SentimentChartProps {
export interface CustomTooltipProps {
active?: boolean;
payload?: string[];
payload?: any[];
total: number;
}

28
src/utils/const.ts Normal file
View File

@ -0,0 +1,28 @@
import { Book, Briefcase, Code, GamepadDirectional, Laptop, Palette } from "lucide-react";
export const MODEL_OPTIONS = [
{
label: "Model XGBoost (Baseline)",
code: "baseline",
desc: "Raw Data (Imbalanced)",
},
{
label: "Model XGBoost (Tuned)",
code: "tuned",
desc: "Hyperparameter Tuned",
},
{
label: "Model XGBoost (Optimized)",
code: "optimized",
desc: "Pipeline (SMOTE + Chi2)",
},
];
export const WORD_LIMIT = 15;
export const professions = [
{ value: "programmer", label: "Programmer", icon: Code },
{ value: "designer", label: "Designer", icon: Palette },
{ value: "student", label: "Student", icon: Book },
{ value: "gamer", label: "Gamer", icon: GamepadDirectional },
];

View File

@ -7,13 +7,7 @@ const TrendChart = dynamic(
})),
{ ssr: false },
);
// const WordCloud = dynamic(
// () =>
// import("../components/dashboards/WordCloud").then((mod) => ({
// default: mod.WordCloud,
// })),
// { ssr: false },
// );
const SentimentChart = dynamic(
() =>
import("../components/dashboards/SentimentChart").then((mod) => ({

View File

@ -1,23 +1,10 @@
import { Frown, Meh, Smile } from "lucide-react";
import { ScrapeResult, WordCloudConfig, WordItem } from "../types";
export const MODEL_OPTIONS = [
{
label: "Model XGBoost (Baseline)",
code: "baseline",
desc: "Raw Data (Imbalanced)",
},
{
label: "Model XGBoost (Tuned)",
code: "tuned",
desc: "Hyperparameter Tuned",
},
{
label: "Model XGBoost (Optimized)",
code: "optimized",
desc: "Pipeline (SMOTE + Chi2)",
},
];
import {
ProfileClientProps,
ScrapeResult,
WordCloudConfig,
WordItem,
} from "../types";
export const getSentimentDisplay = (sentiment: string) => {
switch (sentiment?.toLowerCase()) {
@ -51,8 +38,6 @@ export const getSentimentDisplay = (sentiment: string) => {
}
};
export const WORD_LIMIT = 15;
export const setWordCloud = ({ maxValue, minValue }: WordCloudConfig) => {
const getSize = (value: number) => {
if (maxValue === minValue) return 1.5;
@ -91,9 +76,22 @@ export function getFallbackData(url: string): ScrapeResult {
};
}
export const professions = [
{ value: "programmer", label: "Programmer" },
{ value: "designer", label: "Designer" },
{ value: "student", label: "Student" },
{ value: "gamer", label: "Gamer" },
];
export const formatRupiah = (value: number | string) => {
if (!value) return "Rp 0";
return new Intl.NumberFormat("id-ID", {
style: "currency",
currency: "IDR",
maximumFractionDigits: 0,
}).format(Number(value));
};
export function brandFormat({
preferenceBrand,
}: Pick<ProfileClientProps, "preferenceBrand">) {
const brands = Array.isArray(preferenceBrand)
? preferenceBrand
: preferenceBrand
? [preferenceBrand]
: [];
return { brands };
}