style: improve profile card UI
This commit is contained in:
parent
641bc5bfcf
commit
da42cd48ac
|
|
@ -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";
|
||||||
|
|
@ -166,7 +166,6 @@ model Model {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
modelName String
|
modelName String
|
||||||
description String?
|
description String?
|
||||||
version String?
|
|
||||||
|
|
||||||
accuracy Float
|
accuracy Float
|
||||||
macroF1 Float
|
macroF1 Float
|
||||||
|
|
|
||||||
|
|
@ -8,14 +8,26 @@ export const getAnotherUserData = async () => {
|
||||||
|
|
||||||
if (!session?.user?.email) return null;
|
if (!session?.user?.email) return null;
|
||||||
|
|
||||||
const user = await prisma.user.findUnique({
|
const userData = await prisma.user.findUnique({
|
||||||
where: { email: session.user.email },
|
where: {
|
||||||
|
email: session.user.email,
|
||||||
|
},
|
||||||
select: {
|
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) {
|
} catch (error) {
|
||||||
console.error("Error fetching user data:", error);
|
console.error("Error fetching user data:", error);
|
||||||
return null;
|
return null;
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,11 @@
|
||||||
import { Header } from "@/src/components/dashboards/Header";
|
import { Header } from "@/src/components/dashboards/Header";
|
||||||
import { getAnotherUserData } from "./lib/action";
|
|
||||||
import ProfileClient from "@/src/components/dashboards/ProfileClient";
|
import ProfileClient from "@/src/components/dashboards/ProfileClient";
|
||||||
import { UserGender } from "@prisma/client";
|
|
||||||
|
|
||||||
export default async function ProfilePage() {
|
export default async function ProfilePage() {
|
||||||
const user = await getAnotherUserData();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div className="min-h-screen bg-[#F8FBFF]">
|
||||||
<Header />
|
<Header />
|
||||||
<ProfileClient
|
<ProfileClient />
|
||||||
gender={user?.gender as UserGender}
|
</div>
|
||||||
productReference={user?.productReference || "None"}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ import {
|
||||||
import { Input } from "../ui/input";
|
import { Input } from "../ui/input";
|
||||||
import { Button } from "../ui/button";
|
import { Button } from "../ui/button";
|
||||||
import ResultSection from "./ResultSection";
|
import ResultSection from "./ResultSection";
|
||||||
import { professions } from "@/src/utils/datas";
|
import { professions } from "@/src/utils/const";
|
||||||
|
|
||||||
export default function AnalysisClient() {
|
export default function AnalysisClient() {
|
||||||
const {
|
const {
|
||||||
|
|
@ -49,18 +49,30 @@ export default function AnalysisClient() {
|
||||||
<SelectTrigger
|
<SelectTrigger
|
||||||
className={`w-full mb-6 ${!profession ? "text-gray-500" : "text-black"}`}
|
className={`w-full mb-6 ${!profession ? "text-gray-500" : "text-black"}`}
|
||||||
>
|
>
|
||||||
<SelectValue placeholder="-- Pilih Profesi/Kebutuhan --" />
|
<SelectValue placeholder="Pilih Profesi/Kebutuhan" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
|
|
||||||
<SelectContent
|
<SelectContent
|
||||||
className="bg-card border-border shadow-lg"
|
className="bg-card border-border shadow-lg"
|
||||||
position="popper"
|
position="popper"
|
||||||
>
|
>
|
||||||
{professions.map((item) => (
|
{professions.map((item) => {
|
||||||
<SelectItem key={item.value} value={item.value}>
|
const PIcon = item.icon;
|
||||||
{item.label}
|
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>
|
</SelectItem>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,6 @@
|
||||||
import { CustomTooltipProps } from "@/src/types";
|
import { CustomTooltipProps } from "@/src/types";
|
||||||
import React from "react";
|
|
||||||
|
|
||||||
const CustomTooltip: React.FC<CustomTooltipProps> = ({
|
const CustomTooltip = ({ active, payload, total }: CustomTooltipProps) => {
|
||||||
active,
|
|
||||||
payload,
|
|
||||||
total,
|
|
||||||
}) => {
|
|
||||||
if (active && payload && payload.length) {
|
if (active && payload && payload.length) {
|
||||||
const item = payload[0].payload;
|
const item = payload[0].payload;
|
||||||
const percentage = ((item.value / total) * 100).toFixed(1);
|
const percentage = ((item.value / total) * 100).toFixed(1);
|
||||||
|
|
|
||||||
|
|
@ -14,11 +14,9 @@ import { BrandFilter } from "./BrandFilter";
|
||||||
import { ReviewTable } from "./ReviewTable";
|
import { ReviewTable } from "./ReviewTable";
|
||||||
import { SentimentChart, TrendChart } from "@/src/utils/dImports";
|
import { SentimentChart, TrendChart } from "@/src/utils/dImports";
|
||||||
import { useDashboards } from "@/src/hooks/useDashboard";
|
import { useDashboards } from "@/src/hooks/useDashboard";
|
||||||
import SentimentForm from "./SentimentAnalyzer";
|
|
||||||
import { WordCloud } from "./WordCloud";
|
import { WordCloud } from "./WordCloud";
|
||||||
import AnalysisPage from "@/src/app/analyze/page";
|
|
||||||
import SentimentAnalyzer from "./SentimentAnalyzer";
|
|
||||||
import AnalysisClient from "./AnalysisClient";
|
import AnalysisClient from "./AnalysisClient";
|
||||||
|
import Footer from "./Footer";
|
||||||
|
|
||||||
export default function DashboardClient() {
|
export default function DashboardClient() {
|
||||||
const {
|
const {
|
||||||
|
|
@ -133,7 +131,6 @@ export default function DashboardClient() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
{/* <SentimentAnalyzer /> */}
|
|
||||||
<AnalysisClient />
|
<AnalysisClient />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -154,20 +151,7 @@ export default function DashboardClient() {
|
||||||
<ReviewTable />
|
<ReviewTable />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<footer className="mt-12 border-t pt-8">
|
<Footer />
|
||||||
<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>
|
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -17,7 +17,7 @@ export function ModelInfo({ data }: { data: ModelDB[] }) {
|
||||||
|
|
||||||
if (!data || data.length === 0) {
|
if (!data || data.length === 0) {
|
||||||
return (
|
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">
|
<p className="text-muted-foreground text-sm">
|
||||||
Data model tidak tersedia.
|
Data model tidak tersedia.
|
||||||
</p>
|
</p>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,27 +1,13 @@
|
||||||
"use client";
|
import { ArrowLeft } from "lucide-react";
|
||||||
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 Link from "next/link";
|
import Link from "next/link";
|
||||||
import { motion } from "framer-motion";
|
import ProfileCard from "./ProfileCard";
|
||||||
import { ProfileClientProps } from "@/src/types";
|
import { getAnotherUserData } from "@/src/app/profile/lib/action";
|
||||||
|
|
||||||
export default function ProfileClient({
|
export default async function ProfileClient() {
|
||||||
gender,
|
const user = await getAnotherUserData();
|
||||||
productReference,
|
|
||||||
}: ProfileClientProps) {
|
|
||||||
const session = useSession();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<motion.div
|
<div className="container mx-auto px-4 py-8">
|
||||||
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="flex max-w-xl mx-auto">
|
<div className="flex max-w-xl mx-auto">
|
||||||
<Link
|
<Link
|
||||||
href="/"
|
href="/"
|
||||||
|
|
@ -32,55 +18,14 @@ export default function ProfileClient({
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<motion.div
|
<ProfileCard
|
||||||
initial={{ opacity: 0, scale: 0.95 }}
|
bio={user?.bio || "None"}
|
||||||
animate={{ opacity: 1, scale: 1 }}
|
preferenceBrand={user?.preference?.preferedBrand || "None"}
|
||||||
transition={{ duration: 0.4, ease: "easeOut" }}
|
preferenceOS={user?.preference?.preferredOS || "None"}
|
||||||
className="mx-auto w-full max-w-xl rounded-xl border bg-background shadow-sm mt-4"
|
budgetMax={user?.preference?.budgetMax || 0}
|
||||||
>
|
budgetMin={user?.preference?.budgetMin || 0}
|
||||||
<div className="flex items-center justify-between gap-4 p-6">
|
profession={user?.preference?.profession || "None"}
|
||||||
<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>
|
||||||
</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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { AnalysisResults, ResultProps } from "@/src/types";
|
import { ResultProps } from "@/src/types";
|
||||||
import { motion } from "framer-motion";
|
import { motion } from "framer-motion";
|
||||||
import { Trophy, ExternalLink, CheckCircle2, TrendingUp } from "lucide-react";
|
import { Trophy, ExternalLink, CheckCircle2, TrendingUp } from "lucide-react";
|
||||||
|
|
||||||
|
|
@ -24,7 +24,6 @@ export default function ResultSection({ result }: ResultProps) {
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* ================= HEADER WINNER ================= */}
|
|
||||||
<motion.div
|
<motion.div
|
||||||
variants={{
|
variants={{
|
||||||
hidden: { opacity: 0, y: -20, height: 0 },
|
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"
|
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 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>
|
<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>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
{/* ================= CARDS GRID ================= */}
|
|
||||||
<div className={`grid gap-8 ${getGridClass(result.details.length)}`}>
|
<div className={`grid gap-8 ${getGridClass(result.details.length)}`}>
|
||||||
{result.details.map((item, index) => {
|
{result.details.map((item, index) => {
|
||||||
const isWinner = item.name === result.winning_product;
|
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"
|
: "bg-white border-gray-200 hover:border-primary/50 hover:shadow-lg"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{/* WINNER BADGE (Floating) */}
|
|
||||||
{isWinner && (
|
{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">
|
<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" />
|
<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">
|
<div className="p-6 md:p-8 flex flex-col h-full">
|
||||||
{/* 1. HEADER KARTU */}
|
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
<div className="flex justify-between items-start gap-4">
|
<div className="flex justify-between items-start gap-4">
|
||||||
<h3
|
<h3
|
||||||
|
|
@ -127,9 +122,7 @@ export default function ResultSection({ result }: ResultProps) {
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 2. STATISTIK UTAMA (Progress Bars) */}
|
|
||||||
<div className="space-y-5 mb-8">
|
<div className="space-y-5 mb-8">
|
||||||
{/* Kecocokan Profesi */}
|
|
||||||
<div>
|
<div>
|
||||||
<div className="flex justify-between items-end mb-2">
|
<div className="flex justify-between items-end mb-2">
|
||||||
<span className="text-sm font-medium text-gray-600 flex items-center gap-1.5">
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Sentimen Publik */}
|
|
||||||
<div>
|
<div>
|
||||||
<div className="flex justify-between items-end mb-2">
|
<div className="flex justify-between items-end mb-2">
|
||||||
<span className="text-sm font-medium text-gray-600 flex items-center gap-1.5">
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 3. FOOTER (Keywords) */}
|
|
||||||
<div className="mt-auto pt-5 border-t border-gray-100">
|
<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">
|
<p className="text-xs font-semibold text-gray-400 uppercase tracking-wider mb-3">
|
||||||
Kata Kunci
|
Kata Kunci
|
||||||
|
|
|
||||||
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { WordCloudItemProps, WordItem } from "@/src/types";
|
import { WordCloudItemProps } from "@/src/types";
|
||||||
import { setWordCloud } from "@/src/utils/datas";
|
import { setWordCloud } from "@/src/utils/datas";
|
||||||
|
|
||||||
const WordCloudItem: React.FC<WordCloudItemProps> = ({
|
const WordCloudItem: React.FC<WordCloudItemProps> = ({
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ export function ModelInfoSkeleton() {
|
||||||
<div className="h-4.5 w-13.5 rounded-full bg-gray-200" />
|
<div className="h-4.5 w-13.5 rounded-full bg-gray-200" />
|
||||||
</div>
|
</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-3/4 rounded bg-gray-100" />
|
||||||
<div className="h-4 w-1/2 rounded bg-gray-100" />
|
<div className="h-4 w-1/2 rounded bg-gray-100" />
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { MODEL_OPTIONS } from "../utils/datas";
|
import { MODEL_OPTIONS } from "../utils/const";
|
||||||
|
|
||||||
export const useSentimentForm = () => {
|
export const useSentimentForm = () => {
|
||||||
const [selectedModel, setSelectedModel] = useState(MODEL_OPTIONS[2]);
|
const [selectedModel, setSelectedModel] = useState(MODEL_OPTIONS[2]);
|
||||||
|
|
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
// import { useEffect, useState } from "react";
|
|
||||||
|
|
||||||
// export const useTrendChart = () => {
|
|
||||||
// const isMounted = true;
|
|
||||||
// useEffect(() => {
|
|
||||||
// setIsMounted(true);
|
|
||||||
// }, []);
|
|
||||||
// return { isMounted, setIsMounted };
|
|
||||||
// };
|
|
||||||
|
|
@ -6,7 +6,7 @@ import {
|
||||||
Sentiment,
|
Sentiment,
|
||||||
WordItem,
|
WordItem,
|
||||||
} from "@/src/types";
|
} from "@/src/types";
|
||||||
import { WORD_LIMIT } from "../utils/datas";
|
import { WORD_LIMIT } from "../utils/const";
|
||||||
|
|
||||||
export const useWordCloud = () => {
|
export const useWordCloud = () => {
|
||||||
const [words, setWords] = useState<WordItem[]>([]);
|
const [words, setWords] = useState<WordItem[]>([]);
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
import { UserGender } from "@prisma/client";
|
|
||||||
import { LucideIcon } from "lucide-react";
|
import { LucideIcon } from "lucide-react";
|
||||||
|
import Brand from "@prisma/client";
|
||||||
|
|
||||||
export interface ModelDB {
|
export interface ModelDB {
|
||||||
modelName: string;
|
modelName: string;
|
||||||
description: string;
|
description: string | null;
|
||||||
accuracy: number;
|
accuracy: number;
|
||||||
macroF1: number;
|
macroF1: number;
|
||||||
f1Negative: number;
|
f1Negative: number;
|
||||||
|
|
@ -11,8 +11,13 @@ export interface ModelDB {
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ProfileClientProps {
|
export interface ProfileClientProps {
|
||||||
gender?: UserGender;
|
bio?: string;
|
||||||
productReference?: string;
|
preferenceBrand?: string;
|
||||||
|
preferenceOS: string;
|
||||||
|
budgetMin: number;
|
||||||
|
budgetMax: number;
|
||||||
|
profession: string;
|
||||||
|
id?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Brand {
|
interface Brand {
|
||||||
|
|
@ -62,7 +67,7 @@ export interface SentimentChartProps {
|
||||||
|
|
||||||
export interface CustomTooltipProps {
|
export interface CustomTooltipProps {
|
||||||
active?: boolean;
|
active?: boolean;
|
||||||
payload?: string[];
|
payload?: any[];
|
||||||
total: number;
|
total: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 },
|
||||||
|
];
|
||||||
|
|
@ -7,13 +7,7 @@ const TrendChart = dynamic(
|
||||||
})),
|
})),
|
||||||
{ ssr: false },
|
{ ssr: false },
|
||||||
);
|
);
|
||||||
// const WordCloud = dynamic(
|
|
||||||
// () =>
|
|
||||||
// import("../components/dashboards/WordCloud").then((mod) => ({
|
|
||||||
// default: mod.WordCloud,
|
|
||||||
// })),
|
|
||||||
// { ssr: false },
|
|
||||||
// );
|
|
||||||
const SentimentChart = dynamic(
|
const SentimentChart = dynamic(
|
||||||
() =>
|
() =>
|
||||||
import("../components/dashboards/SentimentChart").then((mod) => ({
|
import("../components/dashboards/SentimentChart").then((mod) => ({
|
||||||
|
|
|
||||||
|
|
@ -1,23 +1,10 @@
|
||||||
import { Frown, Meh, Smile } from "lucide-react";
|
import { Frown, Meh, Smile } from "lucide-react";
|
||||||
import { ScrapeResult, WordCloudConfig, WordItem } from "../types";
|
import {
|
||||||
|
ProfileClientProps,
|
||||||
export const MODEL_OPTIONS = [
|
ScrapeResult,
|
||||||
{
|
WordCloudConfig,
|
||||||
label: "Model XGBoost (Baseline)",
|
WordItem,
|
||||||
code: "baseline",
|
} from "../types";
|
||||||
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 getSentimentDisplay = (sentiment: string) => {
|
export const getSentimentDisplay = (sentiment: string) => {
|
||||||
switch (sentiment?.toLowerCase()) {
|
switch (sentiment?.toLowerCase()) {
|
||||||
|
|
@ -51,8 +38,6 @@ export const getSentimentDisplay = (sentiment: string) => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const WORD_LIMIT = 15;
|
|
||||||
|
|
||||||
export const setWordCloud = ({ maxValue, minValue }: WordCloudConfig) => {
|
export const setWordCloud = ({ maxValue, minValue }: WordCloudConfig) => {
|
||||||
const getSize = (value: number) => {
|
const getSize = (value: number) => {
|
||||||
if (maxValue === minValue) return 1.5;
|
if (maxValue === minValue) return 1.5;
|
||||||
|
|
@ -91,9 +76,22 @@ export function getFallbackData(url: string): ScrapeResult {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export const professions = [
|
export const formatRupiah = (value: number | string) => {
|
||||||
{ value: "programmer", label: "Programmer" },
|
if (!value) return "Rp 0";
|
||||||
{ value: "designer", label: "Designer" },
|
return new Intl.NumberFormat("id-ID", {
|
||||||
{ value: "student", label: "Student" },
|
style: "currency",
|
||||||
{ value: "gamer", label: "Gamer" },
|
currency: "IDR",
|
||||||
];
|
maximumFractionDigits: 0,
|
||||||
|
}).format(Number(value));
|
||||||
|
};
|
||||||
|
|
||||||
|
export function brandFormat({
|
||||||
|
preferenceBrand,
|
||||||
|
}: Pick<ProfileClientProps, "preferenceBrand">) {
|
||||||
|
const brands = Array.isArray(preferenceBrand)
|
||||||
|
? preferenceBrand
|
||||||
|
: preferenceBrand
|
||||||
|
? [preferenceBrand]
|
||||||
|
: [];
|
||||||
|
return { brands };
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue