diff --git a/package-lock.json b/package-lock.json index 032ea75..c84ab29 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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" diff --git a/package.json b/package.json index 3d72051..8785bac 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/prisma/migrations/20260218025343_update_profession_rows_type/migration.sql b/prisma/migrations/20260218025343_update_profession_rows_type/migration.sql new file mode 100644 index 0000000..0bc7654 --- /dev/null +++ b/prisma/migrations/20260218025343_update_profession_rows_type/migration.sql @@ -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; diff --git a/prisma/migrations/20260218030724_udpate_preferred_brand_name/migration.sql b/prisma/migrations/20260218030724_udpate_preferred_brand_name/migration.sql new file mode 100644 index 0000000..4bf47a2 --- /dev/null +++ b/prisma/migrations/20260218030724_udpate_preferred_brand_name/migration.sql @@ -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"; diff --git a/prisma/migrations/migration_lock.toml b/prisma/migrations/migration_lock.toml new file mode 100644 index 0000000..044d57c --- /dev/null +++ b/prisma/migrations/migration_lock.toml @@ -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" diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 2a5b3aa..a4d4031 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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? diff --git a/src/app/api/profile/route.ts b/src/app/api/profile/route.ts new file mode 100644 index 0000000..784994b --- /dev/null +++ b/src/app/api/profile/route.ts @@ -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); +} diff --git a/src/app/profile/lib/action.ts b/src/app/profile/lib/action.ts index cef0306..0728cf5 100644 --- a/src/app/profile/lib/action.ts +++ b/src/app/profile/lib/action.ts @@ -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, diff --git a/src/app/validation/profile.schema.ts b/src/app/validation/profile.schema.ts new file mode 100644 index 0000000..8d40c7a --- /dev/null +++ b/src/app/validation/profile.schema.ts @@ -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), +}); diff --git a/src/components/dashboards/Header.tsx b/src/components/dashboards/Header.tsx index e412c40..0f9696c 100644 --- a/src/components/dashboards/Header.tsx +++ b/src/components/dashboards/Header.tsx @@ -27,7 +27,7 @@ export function Header() { if (!mounted) return null; return ( -
+
diff --git a/src/components/dashboards/ModelInfo.tsx b/src/components/dashboards/ModelInfo.tsx index 2c5eac9..506a9c8 100644 --- a/src/components/dashboards/ModelInfo.tsx +++ b/src/components/dashboards/ModelInfo.tsx @@ -36,7 +36,10 @@ export function ModelInfo({ data }: { data: ModelDB[] }) { - + {data.map((model, index) => (
-

+

{currentModel.description}

diff --git a/src/components/dashboards/ProfileCard.tsx b/src/components/dashboards/ProfileCard.tsx index 178bb3c..7ab1822 100644 --- a/src/components/dashboards/ProfileCard.tsx +++ b/src/components/dashboards/ProfileCard.tsx @@ -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 ( -
+
-

- {session?.data?.user?.name || "Guest User"} -

+ {name && ( +

+ {/* {session?.data?.user?.name || "Guest User"} */} + {name || "Guest User"} +

+ )}

{session?.data?.user?.email || "Belum ada email"}

@@ -82,7 +63,7 @@ export default function ProfileCard({ {profession && ( - {profession} + {toTitleCase(profession)} )}
@@ -109,9 +90,8 @@ export default function ProfileCard({

- {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."}

@@ -174,11 +154,9 @@ export default function ProfileCard({

{formatRupiah(budgetMin)}

-
-

Hingga

-

+

{formatRupiah(budgetMax)}

@@ -194,206 +172,15 @@ export default function ProfileCard({ {showModal && ( - -
-
-
- - -
- -
- -
-
- - -
- -
- -
-
- - -
- -
- -
-
- - -
- -
- -
-
- - -
-
-
- - -
-
- - -
-
-
- -
- - -
-
-
+ )} ); diff --git a/src/components/dashboards/ProfileClient.tsx b/src/components/dashboards/ProfileClient.tsx index a8c29cd..ea270a8 100644 --- a/src/components/dashboards/ProfileClient.tsx +++ b/src/components/dashboards/ProfileClient.tsx @@ -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() { ); diff --git a/src/components/dashboards/ProfileModal.tsx b/src/components/dashboards/ProfileModal.tsx new file mode 100644 index 0000000..f3f8516 --- /dev/null +++ b/src/components/dashboards/ProfileModal.tsx @@ -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 ( + +
+
+
+ + +
+ + {errors.name && ( +

{errors.name.message}

+ )} +
+ +
+
+ + +
+