From 1d4b255a4afae032a7e9daead742f83bc1e882a4 Mon Sep 17 00:00:00 2001 From: RyuJomphol Date: Wed, 9 Sep 2026 22:52:03 +0700 Subject: [PATCH 1/3] feat: integrate frontend and backend profile submit api --- apps/api/src/controller/user.controller.ts | 2 + apps/api/src/router/user.router.ts | 10 +++++ apps/api/src/service/user.service.ts | 5 +++ .../profile/profile-completion-form.tsx | 44 ++++++++++++++++--- apps/web/src/lib/auth-storage.ts | 11 ++++- apps/web/src/types/user.ts | 16 +++++++ 6 files changed, 81 insertions(+), 7 deletions(-) diff --git a/apps/api/src/controller/user.controller.ts b/apps/api/src/controller/user.controller.ts index a877336..97f264d 100644 --- a/apps/api/src/controller/user.controller.ts +++ b/apps/api/src/controller/user.controller.ts @@ -7,11 +7,13 @@ import { Role } from "@prisma/client"; import { BadRequestError, UnauthorizedError } from "../common/errors/app-error.js"; const studentProfileFields = { + name: z.string().trim().min(1, "Name is required").max(100, "Name is too long").optional(), gradeLevel: z.string().trim().min(1, "Grade level is required"), goals: z.string().trim().min(1, "Goals are required"), } as const; const tutorProfileFields = { + name: z.string().trim().min(1, "Name is required").max(100, "Name is too long").optional(), hourlyRate: z .number() .finite("Hourly rate must be finite") diff --git a/apps/api/src/router/user.router.ts b/apps/api/src/router/user.router.ts index f18e6ad..45665e7 100644 --- a/apps/api/src/router/user.router.ts +++ b/apps/api/src/router/user.router.ts @@ -17,6 +17,11 @@ import type { AuthRequest } from "../common/middleware/auth.middleware.js"; * additionalProperties: false * required: [gradeLevel, goals] * properties: + * name: + * type: string + * minLength: 1 + * maxLength: 100 + * example: "Student Name" * gradeLevel: * type: string * example: "10th Grade" @@ -28,6 +33,11 @@ import type { AuthRequest } from "../common/middleware/auth.middleware.js"; * additionalProperties: false * required: [hourlyRate] * properties: + * name: + * type: string + * minLength: 1 + * maxLength: 100 + * example: "Tutor Name" * hourlyRate: * type: number * exclusiveMinimum: 0 diff --git a/apps/api/src/service/user.service.ts b/apps/api/src/service/user.service.ts index cf76539..9452f76 100644 --- a/apps/api/src/service/user.service.ts +++ b/apps/api/src/service/user.service.ts @@ -8,6 +8,7 @@ import { } from "../common/errors/app-error.js"; type ProfileData = { + name?: string; gradeLevel?: string; goals?: string; hourlyRate?: number; @@ -23,6 +24,10 @@ export const userService = { async completeProfile(userId: string, role: Role, data: ProfileData) { let updateData: Partial = { profileComplete: true }; + if (data.name) { + updateData.name = data.name; + } + if (role === "student") { if (!data.gradeLevel || !data.goals) { throw new BadRequestError("Grade level and goals are required for students"); diff --git a/apps/web/src/components/profile/profile-completion-form.tsx b/apps/web/src/components/profile/profile-completion-form.tsx index 00792a9..88d4399 100644 --- a/apps/web/src/components/profile/profile-completion-form.tsx +++ b/apps/web/src/components/profile/profile-completion-form.tsx @@ -10,6 +10,8 @@ import { Input } from "@/components/ui/input"; import { Select } from "@/components/ui/select"; import { Textarea } from "@/components/ui/textarea"; import { apiClient } from "@/lib/api-client"; +import { getToken, getUser, saveUser } from "@/lib/auth-storage"; +import type { PublicUser } from "@/types/user"; type UserRole = "student" | "tutor"; @@ -43,6 +45,19 @@ export function ProfileCompletionForm({ role }: ProfileCompletionFormProps) { const [isPending, setIsPending] = useState(false); const [submitError, setSubmitError] = useState(""); + useEffect(() => { + const token = getToken(); + if (!token) { + router.replace("/login"); + return; + } + + const currentUser = getUser(); + if (currentUser?.name) { + setName(currentUser.name); + } + }, [router]); + useEffect(() => { if (!profileImage) { setProfilePreview(""); @@ -55,6 +70,8 @@ export function ProfileCompletionForm({ role }: ProfileCompletionFormProps) { }, [profileImage]); const isStudent = role === "student"; + const trimmedName = name.trim(); + const nameError = showErrors && !trimmedName; const gradeLevelError = showErrors && !gradeLevel; const goalsError = showErrors && !goals.trim(); const hourlyRateNumber = Number(hourlyRate); @@ -66,19 +83,32 @@ export function ProfileCompletionForm({ role }: ProfileCompletionFormProps) { setShowErrors(true); setSubmitError(""); - const isValid = isStudent - ? Boolean(gradeLevel && goals.trim()) - : Boolean(hourlyRate && Number.isFinite(hourlyRateNumber) && hourlyRateNumber > 0); + const isValid = + Boolean(trimmedName) && + (isStudent + ? Boolean(gradeLevel && goals.trim()) + : Boolean(hourlyRate && Number.isFinite(hourlyRateNumber) && hourlyRateNumber > 0)); if (!isValid) return; const profileData = isStudent - ? { gradeLevel, goals: goals.trim() } - : { hourlyRate: hourlyRateNumber }; + ? { name: trimmedName, gradeLevel, goals: goals.trim() } + : { name: trimmedName, hourlyRate: hourlyRateNumber }; try { setIsPending(true); - await apiClient.post("/users/profile", profileData); + const updatedUser = await apiClient.post("/users/profile", profileData); + + const currentUser = getUser(); + if (currentUser) { + saveUser({ + ...currentUser, + name: updatedUser.name ?? currentUser.name, + profileUrl: updatedUser.profileUrl ?? currentUser.profileUrl, + }); + window.dispatchEvent(new Event("storage")); + } + router.refresh(); setSubmitted(true); } catch (error) { @@ -243,6 +273,8 @@ export function ProfileCompletionForm({ role }: ProfileCompletionFormProps) { value={name} onChange={(event) => setName(event.target.value)} maxLength={100} + error={nameError} + errorMessage="Please enter your name." wrapperClassName="gap-2" className="h-12 rounded-xl px-4 focus:ring-4 focus:ring-primary/10" /> diff --git a/apps/web/src/lib/auth-storage.ts b/apps/web/src/lib/auth-storage.ts index 9c3a16b..173dc3c 100644 --- a/apps/web/src/lib/auth-storage.ts +++ b/apps/web/src/lib/auth-storage.ts @@ -3,7 +3,7 @@ import type { LoginResponse } from "@/types/auth"; const TOKEN_KEY = "tutorist.auth.token"; const USER_KEY = "tutorist.auth.user"; -type AuthUser = LoginResponse["user"]; +export type AuthUser = LoginResponse["user"]; /** * Session lives in localStorage because the API returns the token in the @@ -31,6 +31,15 @@ export function saveSession({ token, user }: LoginResponse): void { } } +export function saveUser(user: AuthUser): void { + if (typeof window === "undefined") return; + try { + window.localStorage.setItem(USER_KEY, JSON.stringify(user)); + } catch { + // Storage unavailable (private mode, quota). + } +} + export function getToken(): string | null { return readItem(TOKEN_KEY); } diff --git a/apps/web/src/types/user.ts b/apps/web/src/types/user.ts index 84a2ca1..557a1c4 100644 --- a/apps/web/src/types/user.ts +++ b/apps/web/src/types/user.ts @@ -20,3 +20,19 @@ export type UserProfile = { /** Subjects the tutor teaches; always empty for students. */ subjects: Subject[]; }; + +/** Shape returned by `POST /users/profile` on completion. */ +export type PublicUser = { + id: string; + name: string | null; + email: string; + role: UserRole; + profileUrl: string | null; + bio: string | null; + hourlyRate: number | null; + gradeLevel: string | null; + goals: string | null; + walletBalance: number; + profileComplete: boolean; + createdAt: string; +}; From 55998bfb8ea53043268e3a14fb8108118e59644d Mon Sep 17 00:00:00 2001 From: RyuJomphol Date: Wed, 9 Sep 2026 22:56:50 +0700 Subject: [PATCH 2/3] feat: --- .prettierrc | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.prettierrc b/.prettierrc index 1a88ab1..2aa8717 100644 --- a/.prettierrc +++ b/.prettierrc @@ -3,5 +3,6 @@ "singleQuote": false, "tabWidth": 2, "trailingComma": "es5", - "printWidth": 100 + "printWidth": 100, + "endOfLine": "lf" } From bf0588784ca64ad0ea32554a2d9c5af93f721598 Mon Sep 17 00:00:00 2001 From: RyuJomphol Date: Sat, 12 Sep 2026 12:11:11 +0700 Subject: [PATCH 3/3] fix: types in user.ts --- apps/web/src/types/user.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/web/src/types/user.ts b/apps/web/src/types/user.ts index 557a1c4..a0d46e7 100644 --- a/apps/web/src/types/user.ts +++ b/apps/web/src/types/user.ts @@ -29,10 +29,10 @@ export type PublicUser = { role: UserRole; profileUrl: string | null; bio: string | null; - hourlyRate: number | null; + hourlyRate: string | null; gradeLevel: string | null; goals: string | null; - walletBalance: number; + walletBalance: string; profileComplete: boolean; createdAt: string; };