feat: integrate get profile modal endpoint
This commit is contained in:
parent
b2008f693f
commit
1fcc44787b
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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";
|
||||
|
|
@ -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"
|
||||
|
|
@ -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?
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
});
|
||||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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,
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
};
|
||||
|
|
@ -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;
|
||||
};
|
||||
|
|
@ -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"
|
||||
>;
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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(" ");
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue