feat: integrate get profile modal endpoint

This commit is contained in:
Mahen 2026-02-18 13:27:50 +07:00
parent b2008f693f
commit 1fcc44787b
20 changed files with 947 additions and 299 deletions

34
package-lock.json generated
View File

@ -9,6 +9,7 @@
"version": "0.1.0",
"dependencies": {
"@base-ui/react": "^1.1.0",
"@hookform/resolvers": "^5.2.2",
"@next-auth/prisma-adapter": "^1.0.7",
"@prisma/adapter-pg": "^7.3.0",
"@prisma/client": "^7.3.0",
@ -29,12 +30,14 @@
"radix-ui": "^1.4.3",
"react": "19.2.3",
"react-dom": "19.2.3",
"react-hook-form": "^7.71.1",
"react-icons": "^5.5.0",
"recharts": "^3.7.0",
"tailwind-merge": "^3.4.0",
"tailwindcss-animate": "^1.0.7",
"ts-node": "^10.9.2",
"tsx": "^4.21.0"
"tsx": "^4.21.0",
"zod": "^4.3.6"
},
"devDependencies": {
"@prisma/adapter-neon": "^7.3.0",
@ -1098,6 +1101,18 @@
"hono": "^4"
}
},
"node_modules/@hookform/resolvers": {
"version": "5.2.2",
"resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-5.2.2.tgz",
"integrity": "sha512-A/IxlMLShx3KjV/HeTcTfaMxdwy690+L/ZADoeaTltLx+CVuzkeVIPuybK3jrRfw7YZnmdKsVVHAlEPIAEUNlA==",
"license": "MIT",
"dependencies": {
"@standard-schema/utils": "^0.3.0"
},
"peerDependencies": {
"react-hook-form": "^7.55.0"
}
},
"node_modules/@humanfs/core": {
"version": "0.19.1",
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
@ -11880,6 +11895,22 @@
"react": "^19.2.3"
}
},
"node_modules/react-hook-form": {
"version": "7.71.1",
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.71.1.tgz",
"integrity": "sha512-9SUJKCGKo8HUSsCO+y0CtqkqI5nNuaDqTxyqPsZPqIwudpj4rCrAz/jZV+jn57bx5gtZKOh3neQu94DXMc+w5w==",
"license": "MIT",
"engines": {
"node": ">=18.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/react-hook-form"
},
"peerDependencies": {
"react": "^16.8.0 || ^17 || ^18 || ^19"
}
},
"node_modules/react-icons": {
"version": "5.5.0",
"resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.5.0.tgz",
@ -13779,7 +13810,6 @@
"version": "4.3.6",
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz",
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"

View File

@ -11,6 +11,7 @@
},
"dependencies": {
"@base-ui/react": "^1.1.0",
"@hookform/resolvers": "^5.2.2",
"@next-auth/prisma-adapter": "^1.0.7",
"@prisma/adapter-pg": "^7.3.0",
"@prisma/client": "^7.3.0",
@ -31,12 +32,14 @@
"radix-ui": "^1.4.3",
"react": "19.2.3",
"react-dom": "19.2.3",
"react-hook-form": "^7.71.1",
"react-icons": "^5.5.0",
"recharts": "^3.7.0",
"tailwind-merge": "^3.4.0",
"tailwindcss-animate": "^1.0.7",
"ts-node": "^10.9.2",
"tsx": "^4.21.0"
"tsx": "^4.21.0",
"zod": "^4.3.6"
},
"devDependencies": {
"@prisma/adapter-neon": "^7.3.0",

View File

@ -0,0 +1,187 @@
-- CreateEnum
CREATE TYPE "UserGender" AS ENUM ('MALE', 'FEMALE', 'OTHER');
-- CreateEnum
CREATE TYPE "Sentiment" AS ENUM ('POSITIVE', 'NEGATIVE', 'NEUTRAL');
-- CreateEnum
CREATE TYPE "OS" AS ENUM ('WINDOWS', 'MACOS', 'LINUX', 'CHROME_OS', 'OTHER');
-- CreateEnum
CREATE TYPE "Brand" AS ENUM ('APPLE', 'ASUS', 'ACER', 'LENOVO', 'HP', 'DELL', 'MSI', 'AXIOO', 'ADVAN', 'ZYREX', 'OTHER');
-- CreateEnum
CREATE TYPE "Profession" AS ENUM ('PROGRAMMER', 'DESIGNER', 'STUDENT', 'GAMER', 'OTHER');
-- CreateTable
CREATE TABLE "Account" (
"id" SERIAL NOT NULL,
"userId" INTEGER NOT NULL,
"type" TEXT NOT NULL,
"provider" TEXT NOT NULL,
"providerAccountId" TEXT NOT NULL,
"refresh_token" TEXT,
"access_token" TEXT,
"expires_at" INTEGER,
"token_type" TEXT,
"scope" TEXT,
"id_token" TEXT,
"session_state" TEXT,
CONSTRAINT "Account_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Session" (
"id" SERIAL NOT NULL,
"sessionToken" TEXT NOT NULL,
"userId" INTEGER NOT NULL,
"expires" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Session_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "VerificationToken" (
"identifier" TEXT NOT NULL,
"token" TEXT NOT NULL,
"expires" TIMESTAMP(3) NOT NULL
);
-- CreateTable
CREATE TABLE "User" (
"id" SERIAL NOT NULL,
"name" TEXT,
"email" TEXT,
"emailVerified" TIMESTAMP(3),
"image" TEXT,
"password" TEXT,
"bio" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "UserPreference" (
"id" SERIAL NOT NULL,
"profession" "Profession",
"preferredOS" "OS",
"preferedBrand" "Brand",
"budgetMin" INTEGER,
"budgetMax" INTEGER,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"userId" INTEGER NOT NULL,
CONSTRAINT "UserPreference_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Product" (
"id" SERIAL NOT NULL,
"name" TEXT NOT NULL,
"brand" TEXT,
"url" TEXT NOT NULL,
"image" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Product_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Review" (
"id" SERIAL NOT NULL,
"content" TEXT NOT NULL,
"sentiment" "Sentiment" NOT NULL,
"confidenceScore" DOUBLE PRECISION NOT NULL,
"keywords" TEXT[],
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"productId" INTEGER NOT NULL,
"modelId" INTEGER,
"userId" INTEGER,
CONSTRAINT "Review_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Analysis" (
"id" SERIAL NOT NULL,
"targetProfession" TEXT NOT NULL,
"generalSentiment" DOUBLE PRECISION NOT NULL,
"compatibilityScore" DOUBLE PRECISION NOT NULL,
"verdict" TEXT NOT NULL,
"topKeywords" TEXT[],
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"userId" INTEGER NOT NULL,
"productId" INTEGER NOT NULL,
"modelId" INTEGER NOT NULL,
CONSTRAINT "Analysis_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Model" (
"id" SERIAL NOT NULL,
"modelName" TEXT NOT NULL,
"description" TEXT,
"accuracy" DOUBLE PRECISION NOT NULL,
"macroF1" DOUBLE PRECISION NOT NULL,
"f1Negative" DOUBLE PRECISION NOT NULL,
"f1Neutral" DOUBLE PRECISION NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Model_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "Account_provider_providerAccountId_key" ON "Account"("provider", "providerAccountId");
-- CreateIndex
CREATE UNIQUE INDEX "Session_sessionToken_key" ON "Session"("sessionToken");
-- CreateIndex
CREATE UNIQUE INDEX "VerificationToken_token_key" ON "VerificationToken"("token");
-- CreateIndex
CREATE UNIQUE INDEX "VerificationToken_identifier_token_key" ON "VerificationToken"("identifier", "token");
-- CreateIndex
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
-- CreateIndex
CREATE UNIQUE INDEX "UserPreference_userId_key" ON "UserPreference"("userId");
-- CreateIndex
CREATE UNIQUE INDEX "Product_url_key" ON "Product"("url");
-- AddForeignKey
ALTER TABLE "Account" ADD CONSTRAINT "Account_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Session" ADD CONSTRAINT "Session_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "UserPreference" ADD CONSTRAINT "UserPreference_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Review" ADD CONSTRAINT "Review_productId_fkey" FOREIGN KEY ("productId") REFERENCES "Product"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Review" ADD CONSTRAINT "Review_modelId_fkey" FOREIGN KEY ("modelId") REFERENCES "Model"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Review" ADD CONSTRAINT "Review_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Analysis" ADD CONSTRAINT "Analysis_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Analysis" ADD CONSTRAINT "Analysis_productId_fkey" FOREIGN KEY ("productId") REFERENCES "Product"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Analysis" ADD CONSTRAINT "Analysis_modelId_fkey" FOREIGN KEY ("modelId") REFERENCES "Model"("id") ON DELETE RESTRICT ON UPDATE CASCADE;

View File

@ -0,0 +1,9 @@
/*
Warnings:
- You are about to drop the column `preferedBrand` on the `UserPreference` table. All the data in the column will be lost.
*/
-- AlterTable
ALTER TABLE "UserPreference" DROP COLUMN "preferedBrand",
ADD COLUMN "preferredBrand" "Brand";

View File

@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "postgresql"

View File

@ -40,6 +40,14 @@ enum Brand {
OTHER
}
enum Profession {
PROGRAMMER
DESIGNER
STUDENT
GAMER
OTHER
}
model Account {
id Int @id @default(autoincrement())
userId Int
@ -96,9 +104,9 @@ model User {
model UserPreference {
id Int @id @default(autoincrement())
profession String?
profession Profession?
preferredOS OS?
preferedBrand Brand?
preferredBrand Brand?
budgetMin Int?
budgetMax Int?

View File

@ -0,0 +1,116 @@
import { getServerSession } from "next-auth";
import { NextResponse } from "next/server";
import { authOptions } from "../auth/[...nextauth]/route";
import prisma from "@/lib/prisma";
export async function POST(req: Request) {
const session = await getServerSession(authOptions);
if (!session?.user?.email) {
return NextResponse.json(
{ success: false, message: "Unauthorized. User belum login." },
{ status: 401 },
);
}
const body = await req.json();
const {
name,
bio,
profession,
preferredBrand,
preferredOS,
budgetMin,
budgetMax,
} = body;
const user = await prisma.user.findUnique({
where: { email: session.user.email },
select: { id: true },
});
if (!user) {
return NextResponse.json(
{ success: false, message: "User tidak ditemukan." },
{ status: 404 },
);
}
try {
const updatedUser = await prisma.user.update({
where: { id: user.id },
data: {
name,
bio,
preference: {
upsert: {
update: {
profession,
preferredBrand,
preferredOS,
budgetMin: budgetMin ? Number(budgetMin) : null,
budgetMax: budgetMax ? Number(budgetMax) : null,
},
create: {
profession,
preferredBrand,
preferredOS,
budgetMin: budgetMin ? Number(budgetMin) : null,
budgetMax: budgetMax ? Number(budgetMax) : null,
},
},
},
},
include: {
preference: true,
},
});
return NextResponse.json({
success: true,
message: "Profile berhasil diupdate",
data: updatedUser,
});
} catch (error) {
return NextResponse.json(
{ success: false, message: "Terjadi kesalahan server." },
{ status: 500 },
);
}
}
export async function GET() {
const session = await getServerSession(authOptions);
if (!session?.user?.email) {
return NextResponse.json(
{ success: false, message: "Unauthorized" },
{ status: 401 },
);
}
const user = await prisma.user.findUnique({
where: { email: session.user.email },
include: {
preference: {
select: {
preferredBrand: true,
preferredOS: true,
profession: true,
budgetMax: true,
budgetMin: true,
},
},
},
});
if (!user) {
return NextResponse.json(
{ success: false, message: "User not found" },
{ status: 404 },
);
}
return NextResponse.json(user);
}

View File

@ -13,12 +13,13 @@ export const getAnotherUserData = async () => {
email: session.user.email,
},
select: {
name: true,
bio: true,
preference: {
select: {
id: true,
profession: true,
preferedBrand: true,
preferredBrand: true,
preferredOS: true,
budgetMin: true,
budgetMax: true,

View File

@ -0,0 +1,35 @@
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",
]);
const osEnum = z.enum(["WINDOWS", "MACOS", "LINUX", "CHROME_OS", "OTHER"]);
export const profileSchema = z.object({
name: z.string().min(2, "Nama minimal 2 karakter"),
bio: z.string().min(10, "Bio minimal 10 karakter"),
profession: professionEnum,
preferredBrand: brandEnum,
preferredOS: osEnum,
budgetMin: z.coerce.number().min(0),
budgetMax: z.coerce.number().min(0),
});

View File

@ -27,7 +27,7 @@ export function Header() {
if (!mounted) return null;
return (
<header className="border-b bg-[#F8FBFF]/50 backdrop-blur-sm sticky top-0 z-50">
<header className="border-b bg-[#F8FBFF]/50 backdrop-blur-sm sticky top-0 z-1">
<div className="container mx-auto px-4 py-4">
<div className="flex items-center justify-between">
<Link href="/" className="flex items-center gap-3 cursor-pointer">

View File

@ -36,7 +36,10 @@ export function ModelInfo({ data }: { data: ModelDB[] }) {
<SelectValue placeholder="Pilih Model" />
</SelectTrigger>
<SelectContent className="bg-card border-border shadow-lg" position="popper">
<SelectContent
className="bg-card border-border shadow-lg"
position="popper"
>
{data.map((model, index) => (
<SelectItem
key={model.modelName + index}
@ -57,7 +60,7 @@ export function ModelInfo({ data }: { data: ModelDB[] }) {
</Badge>
</div>
<p className="mb-6 text-sm text-muted-foreground min-h-[40px]">
<p className="mb-6 text-sm text-muted-foreground min-h-10">
{currentModel.description}
</p>

View File

@ -2,53 +2,31 @@
import { motion } from "framer-motion";
import Image from "next/image";
import {
Pencil,
Wallet,
Laptop,
User,
Monitor,
Fan,
X,
Pickaxe,
Shell,
Save,
} from "lucide-react";
import { Pencil, Wallet, Laptop, User, Monitor, Fan } from "lucide-react";
import { ProfileClientProps } from "@/src/types";
import { Button } from "../ui/button";
import { Separator } from "../ui/separator";
import { brandFormat, formatRupiah } from "@/src/utils/datas";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "../ui/select";
import { formatRupiah, toTitleCase } from "@/src/utils/datas";
import { brandItems, OSItems, professionItems } from "@/src/utils/const";
import { Input } from "../ui/input";
import { Label } from "../ui/label";
import { useProfileClient } from "@/src/hooks/useProfileClient";
import { ProfileModal } from "./ProfileModal";
export default function ProfileCard({
bio,
preferenceBrand,
preferenceOS,
budgetMax,
budgetMin,
}: ProfileClientProps) {
const { brands } = brandFormat({ preferenceBrand });
export default function ProfileCard(props: ProfileClientProps) {
const {
session,
router,
showModal,
name,
bio,
profession,
brand,
OS,
brands,
preferenceOS,
profileDatas,
budgetMin,
budgetMax,
setShowModal,
setProfession,
setBrand,
setOS,
} = useProfileClient();
handleOptimisticUpdate,
} = useProfileClient(props);
return (
<motion.div
@ -68,13 +46,16 @@ export default function ProfileCard({
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 className="absolute bottom-1 right-1 h-4 w-4 rounded-full border-2 border-background bg-green-500"></div>
</div>
<div>
<h1 className="text-2xl font-bold text-card-foreground tracking-tight">
{session?.data?.user?.name || "Guest User"}
</h1>
{name && (
<h1 className="text-2xl font-bold text-card-foreground tracking-tight">
{/* {session?.data?.user?.name || "Guest User"} */}
{name || "Guest User"}
</h1>
)}
<p className="text-sm font-medium text-muted-foreground mb-2">
{session?.data?.user?.email || "Belum ada email"}
</p>
@ -82,7 +63,7 @@ export default function ProfileCard({
{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 className="capitalize">{toTitleCase(profession)}</span>
</span>
)}
</div>
@ -109,9 +90,8 @@ export default function ProfileCard({
</div>
<div className="rounded-xl bg-muted/50 p-4 border border-muted">
<p className="text-sm leading-relaxed text-foreground/90">
{bio
? `${bio}`
: "Belum ada deskripsi profil. Ceritakan sedikit tentang aktivitas dan kebutuhan laptop Anda."}
{bio ||
"Belum ada deskripsi profil. Ceritakan sedikit tentang aktivitas dan kebutuhan laptop Anda."}
</p>
</div>
</div>
@ -174,11 +154,9 @@ export default function ProfileCard({
<p className="text-xl font-semibold">
{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-semibold text-sentiment-positive">
<p className="text-xl font-semibold text-green-600">
{formatRupiah(budgetMax)}
</p>
</div>
@ -194,206 +172,15 @@ export default function ProfileCard({
</div>
{showModal && (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.2, ease: "circOut" }}
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"
>
<form
action=""
className=" flex flex-col bg-card w-1/3 p-6 rounded-2xl border relative"
>
<div className="flex flex-col gap-1 mb-4">
<div className="flex items-center gap-2">
<User className="w-4 h-4" />
<Label htmlFor="username" className="font-semibold">
Nama Lengkap
</Label>
</div>
<Input
id="username"
type="text"
placeholder="Masukkan nama lengkap Anda"
className="border rounded-md focus:ring-2 focus:ring-primary"
required
/>
</div>
<div className="flex flex-col gap-1 mb-4">
<div className="flex items-center gap-2">
<Pickaxe className="w-4 h-4" />
<Label htmlFor="profession" className="font-semibold">
Profesi
</Label>
</div>
<Select
name="profession"
value={profession}
onValueChange={setProfession}
required
>
<SelectTrigger
className={`w-full ${!profession ? "text-gray-500" : "text-black"}`}
>
<SelectValue placeholder="Pilih Profesi/Kebutuhan" />
</SelectTrigger>
<SelectContent
className="bg-card border-border shadow-lg"
position="popper"
>
{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="flex flex-col gap-1 mb-4">
<div className="flex items-center gap-2">
<Laptop className="w-4 h-4" />
<Label htmlFor="brand" className="font-semibold">
Merek Laptop
</Label>
</div>
<Select
name="brand"
value={brand}
onValueChange={setBrand}
required
>
<SelectTrigger
className={`w-full ${!brand ? "text-gray-500" : "text-black"}`}
>
<SelectValue placeholder="Pilih Merek Laptop" />
</SelectTrigger>
<SelectContent
className="bg-card border-border shadow-lg"
position="popper"
>
{brandItems.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="flex flex-col gap-2 mb-4">
<div className="flex items-center gap-2">
<Shell className="w-4 h-4" />
<Label htmlFor="OS" className="font-semibold">
Sistem Operasi
</Label>
</div>
<Select name="OS" value={OS} onValueChange={setOS} required>
<SelectTrigger
className={`w-full -mt-1 ${!profession ? "text-gray-500" : "text-black"}`}
>
<SelectValue placeholder="Pilih Sistem Operasi" />
</SelectTrigger>
<SelectContent
className="bg-card border-border shadow-lg"
position="popper"
>
{OSItems.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="flex flex-col gap-1 mb-4">
<div className="flex items-center gap-2">
<Wallet className="w-4 h-4" />
<Label htmlFor="budget" className="font-semibold">
Rentang Anggaran
</Label>
</div>
<div className="flex gap-4">
<div className="flex-col w-1/2">
<Label htmlFor="budget" className="text-xs mt-2">
Rp (Minimal)
</Label>
<Input
id="budget"
type="text"
placeholder="Rp 0"
className="border rounded-md focus:ring-2 focus:ring-primary mt-1"
required
/>
</div>
<div className="flex-col w-1/2">
<Label htmlFor="budget" className="text-xs mt-2">
Rp (Maksimal)
</Label>
<Input
id="budget"
type="text"
placeholder="Rp 0"
className="border rounded-md focus:ring-2 focus:ring-primary mt-1"
required
/>
</div>
</div>
</div>
<div className="mt-2 flex justify-start gap-4">
<Button onClick={() => setShowModal(false)} variant={"outline"}>
<X />
<span>Cancel</span>
</Button>
<Button type="submit">
<Save />
<span>Simpan</span>
</Button>
</div>
</form>
</motion.div>
<ProfileModal
setShowModal={setShowModal}
professionItems={professionItems}
brandItems={brandItems}
OSItems={OSItems}
userData={profileDatas}
onOptimisticUpdate={handleOptimisticUpdate}
router={router}
/>
)}
</motion.div>
);

View File

@ -1,7 +1,7 @@
import { ArrowLeft } from "lucide-react";
import Link from "next/link";
import ProfileCard from "./ProfileCard";
import { getAnotherUserData } from "@/src/app/profile/lib/action";
import ProfileCard from "./ProfileCard";
export default async function ProfileClient() {
const user = await getAnotherUserData();
@ -19,12 +19,13 @@ export default async function ProfileClient() {
</div>
<ProfileCard
name={user?.name || ""}
profession={user?.preference?.profession || "OTHER"}
bio={user?.bio || "None"}
preferenceBrand={user?.preference?.preferedBrand || "None"}
preferenceOS={user?.preference?.preferredOS || "None"}
preferenceBrand={user?.preference?.preferredBrand || "OTHER"}
preferenceOS={user?.preference?.preferredOS || "OTHER"}
budgetMax={user?.preference?.budgetMax || 0}
budgetMin={user?.preference?.budgetMin || 0}
profession={user?.preference?.profession || "None"}
/>
</div>
);

View File

@ -0,0 +1,280 @@
"use client";
import { Controller } from "react-hook-form";
import { motion } from "framer-motion";
import { User, Pickaxe, Laptop, Shell, Wallet, Save, X } from "lucide-react";
import { ExtendedModalProps } from "@/src/types";
import { Label } from "../ui/label";
import { Input } from "../ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "../ui/select";
import { Button } from "../ui/button";
import { useProfileModal } from "@/src/hooks/useProfileModal";
export const ProfileModal = ({
setShowModal,
professionItems,
brandItems,
OSItems,
userData,
onOptimisticUpdate,
router,
}: ExtendedModalProps) => {
const { control, errors, isSubmitting, onSubmit, register, handleSubmit } =
useProfileModal({
userData,
router,
onOptimisticUpdate,
setShowModal,
});
return (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.2, ease: "circOut" }}
className="fixed inset-0 flex items-center justify-center bg-black/50 z-1"
>
<form
onSubmit={handleSubmit(onSubmit)}
className="flex flex-col bg-card w-1/3 p-6 rounded-2xl border relative gap-4"
>
<div className="flex flex-col gap-1">
<div className="flex items-center gap-2">
<User className="w-4 h-4" />
<Label htmlFor="name" className="font-semibold">
Nama Lengkap
</Label>
</div>
<Input
id="name"
placeholder="Masukkan nama lengkap Anda"
{...register("name")}
className="border rounded-md focus:ring-2 focus:ring-primary"
/>
{errors.name && (
<p className="text-red-500 text-xs">{errors.name.message}</p>
)}
</div>
<div className="flex flex-col gap-1">
<div className="flex items-center gap-2">
<User className="w-4 h-4" />
<Label htmlFor="bio" className="font-semibold">
Bio
</Label>
</div>
<textarea
{...register("bio")}
placeholder="Masukkan bio Anda"
className="border rounded-md focus:ring-2 focus:ring-primary p-2 resize-none"
/>
{errors.bio && (
<p className="text-red-500 text-xs">{errors.bio.message}</p>
)}
</div>
<div className="flex flex-col gap-1">
<div className="flex items-center gap-2">
<Pickaxe className="w-4 h-4" />
<Label className="font-semibold">Profesi</Label>
</div>
<Controller
control={control}
name="profession"
render={({ field }) => (
<Select
value={field.value || undefined}
onValueChange={field.onChange}
>
<SelectTrigger
className={`w-full ${!field.value ? "text-gray-500" : "text-black"}`}
>
<SelectValue placeholder="Pilih Profesi/Kebutuhan" />
</SelectTrigger>
<SelectContent
className="bg-card border-border shadow-lg"
position="popper"
>
{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">
<PIcon className="h-4 w-4 text-muted-foreground" />
<span>{item.label}</span>
</div>
</SelectItem>
);
})}
</SelectContent>
</Select>
)}
/>
{errors.profession && (
<p className="text-red-500 text-xs">{errors.profession.message}</p>
)}
</div>
<div className="flex flex-col gap-1">
<div className="flex items-center gap-2">
<Laptop className="w-4 h-4" />
<Label className="font-semibold">Merek Laptop</Label>
</div>
<Controller
control={control}
name="preferredBrand"
render={({ field }) => (
<Select
value={field.value || undefined}
onValueChange={field.onChange}
>
<SelectTrigger
className={`w-full ${
!field.value ? "text-gray-500" : "text-black"
}`}
>
<SelectValue placeholder="Pilih Merek Laptop" />
</SelectTrigger>
<SelectContent
className="bg-card border-border shadow-lg"
position="popper"
>
{brandItems.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">
<PIcon className="h-4 w-4 text-muted-foreground" />
<span>{item.label}</span>
</div>
</SelectItem>
);
})}
</SelectContent>
</Select>
)}
/>
{errors.preferredBrand && (
<p className="text-red-500 text-xs">
{errors.preferredBrand.message}
</p>
)}
</div>
<div className="flex flex-col gap-1">
<div className="flex items-center gap-2">
<Shell className="w-4 h-4" />
<Label className="font-semibold">Sistem Operasi</Label>
</div>
<Controller
control={control}
name="preferredOS"
render={({ field }) => (
<Select
value={field.value || undefined}
onValueChange={field.onChange}
>
<SelectTrigger
className={`w-full ${
!field.value ? "text-gray-500" : "text-black"
}`}
>
<SelectValue placeholder="Pilih Sistem Operasi" />
</SelectTrigger>
<SelectContent
className="bg-card border-border shadow-lg"
position="popper"
>
{OSItems.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">
<PIcon className="h-4 w-4 text-muted-foreground" />
<span>{item.label}</span>
</div>
</SelectItem>
);
})}
</SelectContent>
</Select>
)}
/>
{errors.preferredOS && (
<p className="text-red-500 text-xs">{errors.preferredOS.message}</p>
)}
</div>
<div className="flex flex-col gap-1">
<div className="flex items-center gap-2">
<Wallet className="w-4 h-4" />
<Label className="font-semibold">Rentang Anggaran</Label>
</div>
<div className="flex gap-4">
<div className="flex-col w-1/2">
<Label className="text-xs mt-2">Rp (Minimal)</Label>
<Input
type="number"
{...register("budgetMin", { valueAsNumber: true })}
placeholder="Rp 0"
className="border rounded-md focus:ring-2 focus:ring-primary mt-1"
/>
{errors.budgetMin && (
<p className="text-red-500 text-xs">
{errors.budgetMin.message}
</p>
)}
</div>
<div className="flex-col w-1/2">
<Label className="text-xs mt-2">Rp (Maksimal)</Label>
<Input
type="number"
{...register("budgetMax", { valueAsNumber: true })}
placeholder="Rp 0"
className="border rounded-md focus:ring-2 focus:ring-primary mt-1"
/>
{errors.budgetMax && (
<p className="text-red-500 text-xs">
{errors.budgetMax.message}
</p>
)}
</div>
</div>
</div>
<div className="mt-2 flex justify-start gap-4">
<Button
type="button"
onClick={() => setShowModal(false)}
variant="outline"
>
<X className="mr-2" />
<span>Cancel</span>
</Button>
<Button type="submit" disabled={isSubmitting}>
<Save className="mr-2" />
<span>Simpan</span>
</Button>
</div>
</form>
</motion.div>
);
};

View File

@ -1,24 +1,95 @@
"use client";
import { useState, useEffect } from "react";
import { useSession } from "next-auth/react";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { ProfileClientProps, ProfileFormData, ProfileState } from "@/src/types";
import { Brand, OS, Profession } from "@prisma/client";
import { brandFormat } from "../utils/datas";
export const useProfileClient = () => {
export const useProfileClient = (props: ProfileClientProps) => {
const session = useSession();
const router = useRouter();
const [showModal, setShowModal] = useState(false);
const [profession, setProfession] = useState("");
const [brand, setBrand] = useState("");
const [OS, setOS] = useState("");
const [profileDatas, setProfileDatas] = useState<ProfileState>({
name: props.name || "",
bio: props.bio || "",
preference: {
profession: props.profession || "",
preferredBrand: props.preferenceBrand || "",
preferredOS: props.preferenceOS || "",
budgetMin: props.budgetMin ?? 0,
budgetMax: props.budgetMax ?? 0,
},
});
useEffect(() => {
setProfileDatas({
name: props.name || "",
bio: props.bio || "",
preference: {
profession: props.profession || "",
preferredBrand: props.preferenceBrand || "",
preferredOS: props.preferenceOS || "",
budgetMin: props.budgetMin ?? 0,
budgetMax: props.budgetMax ?? 0,
},
});
}, [props]);
const handleOptimisticUpdate = (newData: ProfileFormData) => {
setProfileDatas((prev) => {
const updatedPreference = {
...prev.preference,
profession:
(newData.profession as Profession) || prev.preference.profession,
preferredBrand:
(newData.preferredBrand as Brand) || prev.preference.preferredBrand,
preferredOS: (newData.preferredOS as OS) || prev.preference.preferredOS,
budgetMin: newData.budgetMin ? Number(newData.budgetMin) : 0,
budgetMax: newData.budgetMax ? Number(newData.budgetMax) : 0,
};
return {
...prev,
name: newData.name,
bio: newData.bio,
preference: updatedPreference,
};
});
};
const { name, bio, preference } = profileDatas;
const {
preferredBrand: preferenceBrand,
preferredOS: preferenceOS,
budgetMin,
budgetMax,
profession,
} = preference;
const { brands } = brandFormat({ preferenceBrand });
return {
session,
router,
showModal,
name,
bio,
profession,
brand,
OS,
brands,
preferenceBrand,
preferenceOS,
profileDatas,
budgetMin,
budgetMax,
handleOptimisticUpdate,
setShowModal,
setProfession,
setBrand,
setOS,
};
};

View File

@ -0,0 +1,43 @@
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { profileSchema } from "../app/validation/profile.schema";
import { updateProfileService } from "../services/profile.service";
import { ProfileFormData, UseProfileModalProps } from "../types";
export const useProfileModal = ({
userData,
router,
onOptimisticUpdate,
setShowModal,
}: UseProfileModalProps) => {
const pref = userData?.preference || {};
const {
register,
handleSubmit,
control,
formState: { errors, isSubmitting },
} = useForm<ProfileFormData>({
resolver: zodResolver(profileSchema),
defaultValues: {
name: userData?.name,
bio: userData?.bio,
profession: pref.profession ?? "OTHER",
preferredBrand: pref.preferredBrand ?? "OTHER",
preferredOS: pref.preferredOS ?? "OTHER",
budgetMin: pref.budgetMin ?? 0,
budgetMax: pref.budgetMax ?? 0,
},
});
const onSubmit = async (data: ProfileFormData) => {
setShowModal(false);
onOptimisticUpdate(data);
await updateProfileService(data)
.then(() => router.refresh())
.catch((err) => console.error("Update failed:", err));
};
return { register, handleSubmit, control, errors, isSubmitting, onSubmit };
};

View File

@ -0,0 +1,16 @@
import { ProfileFormData } from "../types";
export const updateProfileService = async (formData: ProfileFormData) => {
const response = await fetch("/api/profile", {
method: "POST",
body: JSON.stringify(formData),
});
const result = await response.json();
if (!response.ok) {
throw new Error(result.message || "Failed to update profile");
}
return result;
};

View File

@ -1,5 +1,7 @@
import { LucideIcon } from "lucide-react";
import Brand, { Sentiment } from "@prisma/client";
import { OS, Profession, Sentiment, Brand } from "@prisma/client";
import z from "zod";
import { profileSchema } from "../app/validation/profile.schema";
export interface ModelDB {
modelName: string;
@ -11,20 +13,21 @@ export interface ModelDB {
}
export interface ProfileClientProps {
name: string;
bio?: string;
preferenceBrand?: string;
preferenceOS: string;
preferenceBrand: Brand;
preferenceOS: OS;
budgetMin: number;
budgetMax: number;
profession: string;
profession: Profession;
id?: number;
}
interface Brand {
name: string;
count: number;
logo?: string;
}
// interface Brand {
// name: string;
// count: number;
// logo?: string;
// }
export interface BrandFilterProps {
// brands: Brand[];
@ -202,6 +205,52 @@ export interface ResultProps {
result: AnalysisResults | null;
}
export type ProfileFormData = z.input<typeof profileSchema>;
export type ProfileModalProps = {
setShowModal: React.Dispatch<React.SetStateAction<boolean>>;
professionItems: { value: string; label: string; icon: any }[];
brandItems: { value: string; label: string; icon: any }[];
OSItems: { value: string; label: string; icon: any }[];
};
export interface WordCLoud {
topKeywords: string;
}
export interface ModalProps extends ProfileModalProps {
userData: {
name: string;
bio: string;
preference: {
profession: Profession;
preferredBrand: Brand;
preferredOS: OS;
budgetMin: number;
budgetMax: number;
};
};
}
export interface ExtendedModalProps extends ProfileModalProps {
userData: any;
onOptimisticUpdate: (data: ProfileFormData) => void;
router: any;
}
export interface ProfileState {
name: string;
bio: string;
preference: {
profession: Profession | string;
preferredBrand: Brand | string;
preferredOS: OS | string;
budgetMin: number;
budgetMax: number;
};
}
export type UseProfileModalProps = Pick<
ExtendedModalProps,
"userData" | "router" | "onOptimisticUpdate" | "setShowModal"
>;

View File

@ -29,22 +29,22 @@ export const MODEL_OPTIONS = [
export const WORD_LIMIT = 15;
export const professionItems = [
{ value: "programmer", label: "Programmer", icon: Code },
{ value: "designer", label: "Designer", icon: Palette },
{ value: "student", label: "Student", icon: Book },
{ value: "gamer", label: "Gamer", icon: GamepadDirectional },
{ value: "PROGRAMMER", label: "Programmer", icon: Code },
{ value: "DESIGNER", label: "Designer", icon: Palette },
{ value: "STUDENT", label: "Student", icon: Book },
{ value: "GAMER", label: "Gamer", icon: GamepadDirectional },
];
export const brandItems = [
{ value: "asus", label: "Asus", icon: SiAsus },
{ value: "acer", label: "Acer", icon: SiAcer },
{ value: "lenovo", label: "Lenovo", icon: SiLenovo },
{ value: "other", label: "Other", icon: LucideCircleEllipsis },
{ value: "ASUS", label: "Asus", icon: SiAsus },
{ value: "ACER", label: "Acer", icon: SiAcer },
{ value: "LENOVO", label: "Lenovo", icon: SiLenovo },
{ value: "OTHER", label: "Other", icon: LucideCircleEllipsis },
];
export const OSItems = [
{ value: "windows", label: "Windows", icon: FaWindows },
{ value: "macos", label: "Macos", icon: SiMacos },
{ value: "linux", label: "Linux", icon: SiLinux },
{ value: "other", label: "Other", icon: LucideCircleEllipsis },
{ value: "WINDOWS", label: "Windows", icon: FaWindows },
{ value: "MACOS", label: "Macos", icon: SiMacos },
{ value: "LINUX", label: "Linux", icon: SiLinux },
{ value: "OTHER", label: "Other", icon: LucideCircleEllipsis },
];

View File

@ -1,10 +1,6 @@
import { Frown, Meh, Smile } from "lucide-react";
import {
ProfileClientProps,
ScrapeResult,
WordCloudConfig,
WordItem,
} from "../types";
import { ScrapeResult, WordCloudConfig, WordItem } from "../types";
import { Brand } from "@prisma/client";
export const getSentimentDisplay = (sentiment: string) => {
switch (sentiment?.toLowerCase()) {
@ -85,13 +81,23 @@ export const formatRupiah = (value: number | string) => {
}).format(Number(value));
};
export function brandFormat({
export const brandFormat = ({
preferenceBrand,
}: Pick<ProfileClientProps, "preferenceBrand">) {
}: {
preferenceBrand: Brand | string;
}) => {
const brands = Array.isArray(preferenceBrand)
? preferenceBrand
: preferenceBrand
? [preferenceBrand]
: [];
return { brands };
};
export function toTitleCase(str: string) {
return str
.toLowerCase()
.split(/[\s-_]+/)
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(" ");
}