From 7cbad9b0c7fd4bf9273c23a748d8b9a874c95987 Mon Sep 17 00:00:00 2001 From: note Date: Tue, 8 Sep 2026 12:26:40 +0700 Subject: [PATCH 1/8] feat: gay --- apps/web/src/app/profile/page.tsx | 17 ++ apps/web/src/components/Navbar.tsx | 16 +- .../components/profile/profile-edit-form.tsx | 283 ++++++++++++++++++ apps/web/src/types/profile.ts | 21 ++ 4 files changed, 335 insertions(+), 2 deletions(-) create mode 100644 apps/web/src/app/profile/page.tsx create mode 100644 apps/web/src/components/profile/profile-edit-form.tsx create mode 100644 apps/web/src/types/profile.ts diff --git a/apps/web/src/app/profile/page.tsx b/apps/web/src/app/profile/page.tsx new file mode 100644 index 0000000..03239de --- /dev/null +++ b/apps/web/src/app/profile/page.tsx @@ -0,0 +1,17 @@ +import type { Metadata } from "next"; +import { ProfileEditForm } from "@/components/profile/profile-edit-form"; + +export const metadata: Metadata = { + title: "Edit your profile | Tutorist", + description: "Update your Tutorist profile information.", +}; + +export default function ProfilePage() { + return ( +
+
+ +
+
+ ); +} diff --git a/apps/web/src/components/Navbar.tsx b/apps/web/src/components/Navbar.tsx index 252c8c0..41f9397 100644 --- a/apps/web/src/components/Navbar.tsx +++ b/apps/web/src/components/Navbar.tsx @@ -11,6 +11,7 @@ const NAV_LINKS = [ { label: "Find tutors", href: "/tutors" }, { label: "My classroom", href: "/classroom" }, { label: "Message", href: "/message" }, + { label: "My profile", href: "/profile" }, ]; const FOCUSABLE_SELECTOR = @@ -113,7 +114,11 @@ export function Navbar({ isLoggedIn = false, userName, userMoney, profileUrl }:

${userMoney}

- @@ -216,7 +221,14 @@ export function Navbar({ isLoggedIn = false, userName, userMoney, profileUrl }: )} - diff --git a/apps/web/src/components/profile/profile-edit-form.tsx b/apps/web/src/components/profile/profile-edit-form.tsx new file mode 100644 index 0000000..2749346 --- /dev/null +++ b/apps/web/src/components/profile/profile-edit-form.tsx @@ -0,0 +1,283 @@ +"use client"; + +import { type FormEvent, useEffect, useRef, useState } from "react"; +import { GraduationCap, UserRound } from "lucide-react"; +import Image from "next/image"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; + +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Textarea } from "@/components/ui/textarea"; +import { apiClient } from "@/lib/api-client"; +import { getToken, saveSession } from "@/lib/auth-storage"; +import type { ProfileUpdateResponse, UserProfile } from "@/types/profile"; + +type Fields = { gradeLevel: string; goals: string; hourlyRate: string }; + +function fieldsFromProfile(profile: UserProfile): Fields { + return { + gradeLevel: profile.gradeLevel ?? "", + goals: profile.goals ?? "", + hourlyRate: profile.hourlyRate === null ? "" : String(profile.hourlyRate), + }; +} + +export function ProfileEditForm() { + const router = useRouter(); + const [profile, setProfile] = useState(null); + const [fields, setFields] = useState({ gradeLevel: "", goals: "", hourlyRate: "" }); + const [touched, setTouched] = useState>>({}); + const [loading, setLoading] = useState(true); + const [needsLogin, setNeedsLogin] = useState(false); + const [loadError, setLoadError] = useState(""); + const [attempt, setAttempt] = useState(0); + const [saving, setSaving] = useState(false); + const [saveError, setSaveError] = useState(""); + const [saved, setSaved] = useState(false); + const submitting = useRef(false); + + useEffect(() => { + let active = true; + if (!getToken()) { + setNeedsLogin(true); + setLoading(false); + return; + } + setLoading(true); + setLoadError(""); + apiClient + .get("/users/me") + .then((current) => { + if (!active) return; + setProfile(current); + setFields(fieldsFromProfile(current)); + }) + .catch((error: unknown) => { + if (active) { + setLoadError(error instanceof Error ? error.message : "Unable to load your profile."); + } + }) + .finally(() => { + if (active) setLoading(false); + }); + return () => { + active = false; + }; + }, [attempt]); + + const isStudent = profile?.role === "student"; + const rate = Number(fields.hourlyRate); + // Match the controller schema: trimmed required strings, or a finite positive number. + const errors = { + gradeLevel: fields.gradeLevel.trim() ? "" : "Please enter your grade level.", + goals: fields.goals.trim() ? "" : "Please enter your learning goals.", + hourlyRate: + fields.hourlyRate.trim() && Number.isFinite(rate) && rate > 0 + ? "" + : "Enter an hourly rate greater than 0.", + }; + const valid = isStudent ? !errors.gradeLevel && !errors.goals : !errors.hourlyRate; + const changed = Boolean( + profile && + (isStudent + ? fields.gradeLevel.trim() !== (profile.gradeLevel ?? "").trim() || + fields.goals.trim() !== (profile.goals ?? "").trim() + : fields.hourlyRate.trim() !== "" && rate !== profile.hourlyRate) + ); + + function edit(field: keyof Fields, value: string) { + setFields((current) => ({ ...current, [field]: value })); + setSaved(false); + setSaveError(""); + } + + function touch(field: keyof Fields) { + setTouched((current) => ({ ...current, [field]: true })); + } + + async function handleSubmit(event: FormEvent) { + event.preventDefault(); + if (!profile || submitting.current || !valid || !changed) return; + submitting.current = true; + setSaving(true); + setSaveError(""); + setSaved(false); + try { + const updated = await apiClient.post( + "/users/profile", + isStudent + ? { gradeLevel: fields.gradeLevel.trim(), goals: fields.goals.trim() } + : { hourlyRate: rate } + ); + // Refresh from the persisted response, including database decimal rounding. + const current: UserProfile = { + ...profile, + ...updated, + hourlyRate: updated.hourlyRate === null ? null : Number(updated.hourlyRate), + walletBalance: Number(updated.walletBalance), + }; + setProfile(current); + setFields(fieldsFromProfile(current)); + setTouched({}); + const token = getToken(); + if (token) { + const { id, name, email, role, profileUrl } = current; + saveSession({ token, user: { id, name, email, role, profileUrl } }); + } + setSaved(true); + router.refresh(); + } catch (error) { + setSaveError( + error instanceof Error ? error.message : "Unable to save your profile. Please try again." + ); + } finally { + submitting.current = false; + setSaving(false); + } + } + + if (loading) { + return ( +

+ Loading your profile... +

+ ); + } + + if (needsLogin) { + return ( +
+

+ Sign in to edit your profile +

+ + Sign in + +
+ ); + } + + if (!profile) { + return ( +
+

Your profile

+

+ {loadError} +

+
+ + + Sign in + +
+
+ ); + } + + return ( +
+ +
+

+ Edit {isStudent ? "student" : "tutor"} profile +

+

All fields below are required.

+
+
+ Profile information + {isStudent ? ( + <> + edit("gradeLevel", event.target.value)} + onBlur={() => touch("gradeLevel")} + error={Boolean(touched.gradeLevel && errors.gradeLevel)} + errorMessage={errors.gradeLevel} + placeholder="For example, Grade 10 or University" + className="h-12 rounded-xl" + /> +