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" } diff --git a/apps/api/src/controller/user.controller.ts b/apps/api/src/controller/user.controller.ts index c2ca9b6..0547bf0 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 a491dad..e8930ab 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 fad0901..1b506b1 100644 --- a/apps/api/src/service/user.service.ts +++ b/apps/api/src/service/user.service.ts @@ -13,6 +13,7 @@ import { } from "../common/errors/app-error.js"; type ProfileData = { + name?: string; gradeLevel?: string; goals?: string; hourlyRate?: number; @@ -64,6 +65,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 0f7aeef..04a2805 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 6a1bb22..b75e438 100644 --- a/apps/web/src/types/user.ts +++ b/apps/web/src/types/user.ts @@ -21,6 +21,22 @@ export type UserProfile = { 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: string | null; + gradeLevel: string | null; + goals: string | null; + walletBalance: string; + profileComplete: boolean; + createdAt: string; +}; + /** Shape of a successful `PUT /users/profile` response. */ export type ProfileUpdateResponse = UserProfile & { profileComplete: boolean;