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}
-
+ {!validProfileUrl && (
+
+ Your profile needs a valid photo URL before changes can be saved. Photo uploads are
+ not available yet.
+
+ )}
{saveError && (
{saveError}
From 9156a12a1b5ec4bb604f19db1299c93d0cac4df9 Mon Sep 17 00:00:00 2001
From: note
Date: Sat, 12 Sep 2026 01:18:02 +0700
Subject: [PATCH 4/8] feat(profile): integrate profile updates for students and
tutors
---
apps/api/src/controller/user.controller.ts | 2 +-
apps/api/src/router/user.router.ts | 2 +
apps/api/src/service/user.service.ts | 2 +-
.../components/profile/profile-edit-form.tsx | 40 +++++++++----------
apps/web/src/types/user.ts | 12 +++++-
5 files changed, 35 insertions(+), 23 deletions(-)
diff --git a/apps/api/src/controller/user.controller.ts b/apps/api/src/controller/user.controller.ts
index a877336..540132b 100644
--- a/apps/api/src/controller/user.controller.ts
+++ b/apps/api/src/controller/user.controller.ts
@@ -23,7 +23,7 @@ const tutorProfileSchema = z.object(tutorProfileFields).strict();
const editableProfileFields = {
name: z.string().trim().min(1, "Name is required").max(100, "Name is too long"),
- profileUrl: z.string().trim().url("Please enter a valid profile image URL"),
+ profileUrl: z.string().trim().url("Please enter a valid profile image URL").nullable(),
bio: z.string().trim().max(1000, "Bio must be at most 1000 characters").nullable(),
} as const;
diff --git a/apps/api/src/router/user.router.ts b/apps/api/src/router/user.router.ts
index f18e6ad..456fad7 100644
--- a/apps/api/src/router/user.router.ts
+++ b/apps/api/src/router/user.router.ts
@@ -71,6 +71,7 @@ import type { AuthRequest } from "../common/middleware/auth.middleware.js";
* profileUrl:
* type: string
* format: uri
+ * nullable: true
* example: "https://cdn.example.com/student.jpg"
* bio:
* type: string
@@ -100,6 +101,7 @@ import type { AuthRequest } from "../common/middleware/auth.middleware.js";
* profileUrl:
* type: string
* format: uri
+ * nullable: true
* example: "https://cdn.example.com/tutor.jpg"
* bio:
* type: string
diff --git a/apps/api/src/service/user.service.ts b/apps/api/src/service/user.service.ts
index cf76539..e420c2f 100644
--- a/apps/api/src/service/user.service.ts
+++ b/apps/api/src/service/user.service.ts
@@ -15,7 +15,7 @@ type ProfileData = {
type ProfileUpdateData = ProfileData & {
name: string;
- profileUrl: string;
+ profileUrl: string | null;
bio: string | null;
};
diff --git a/apps/web/src/components/profile/profile-edit-form.tsx b/apps/web/src/components/profile/profile-edit-form.tsx
index 33786fd..15f3e3e 100644
--- a/apps/web/src/components/profile/profile-edit-form.tsx
+++ b/apps/web/src/components/profile/profile-edit-form.tsx
@@ -10,10 +10,10 @@ import { Button } from "@/components/ui/button";
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, saveSession } from "@/lib/auth-storage";
+import { ApiError, apiClient } from "@/lib/api-client";
+import { clearSession, getToken, saveSession } from "@/lib/auth-storage";
import { GRADE_LEVELS } from "@/lib/grade-levels";
-import type { ProfileUpdateResponse, UserProfile } from "@/types/profile";
+import type { ProfileUpdateResponse, UserProfile } from "@/types/user";
type Fields = { gradeLevel: string; goals: string; hourlyRate: string };
@@ -61,6 +61,11 @@ export function ProfileEditForm() {
})
.catch((error: unknown) => {
if (active) {
+ if (error instanceof ApiError && error.status === 401) {
+ clearSession();
+ setNeedsLogin(true);
+ return;
+ }
setLoadError(error instanceof Error ? error.message : "Unable to load your profile.");
}
})
@@ -83,19 +88,10 @@ export function ProfileEditForm() {
? ""
: "Enter an hourly rate greater than 0.",
};
- const profileUrl = profile?.profileUrl ?? "";
- let validProfileUrl = false;
- try {
- new URL(profileUrl.trim());
- validProfileUrl = true;
- } catch {
- // The update API requires an absolute profile image URL.
- }
const valid =
Boolean(name.trim()) &&
name.trim().length <= 100 &&
about.trim().length <= 1000 &&
- validProfileUrl &&
(isStudent ? !errors.gradeLevel && !errors.goals : !errors.hourlyRate);
const changed = Boolean(
profile &&
@@ -127,7 +123,7 @@ export function ProfileEditForm() {
try {
const updated = await apiClient.put("/users/profile", {
name: name.trim(),
- profileUrl: profileUrl.trim(),
+ profileUrl: profile.profileUrl,
bio: about.trim() || null,
...(isStudent
? { gradeLevel: fields.gradeLevel.trim(), goals: fields.goals.trim() }
@@ -153,6 +149,11 @@ export function ProfileEditForm() {
setSaved(true);
router.refresh();
} catch (error) {
+ if (error instanceof ApiError && error.status === 401) {
+ clearSession();
+ setNeedsLogin(true);
+ return;
+ }
setSaveError(
error instanceof Error ? error.message : "Unable to save your profile. Please try again."
);
@@ -226,7 +227,12 @@ export function ProfileEditForm() {
)}
-
+
Change photo
@@ -329,12 +335,6 @@ export function ProfileEditForm() {
{saving ? "Saving your profile..." : saved ? "Your profile has been saved." : ""}
- {!validProfileUrl && (
-
- Your profile needs a valid photo URL before changes can be saved. Photo uploads are
- not available yet.
-
- )}
{saveError && (
{saveError}
diff --git a/apps/web/src/types/user.ts b/apps/web/src/types/user.ts
index 84a2ca1..0253b3e 100644
--- a/apps/web/src/types/user.ts
+++ b/apps/web/src/types/user.ts
@@ -1,7 +1,7 @@
import type { UserRole } from "@/types/auth";
import type { Subject } from "@/types/subject";
-/** Shape of `GET /users/me` and the body echoed back by `PATCH /users/me`. */
+/** Shape of `GET /users/me`. */
export type UserProfile = {
id: string;
name: string | null;
@@ -20,3 +20,13 @@ export type UserProfile = {
/** Subjects the tutor teaches; always empty for students. */
subjects: Subject[];
};
+
+// PUT returns Prisma decimals as JSON strings and does not include subjects.
+export type ProfileUpdateResponse = Omit<
+ UserProfile,
+ "hourlyRate" | "walletBalance" | "subjects"
+> & {
+ hourlyRate: string | number | null;
+ walletBalance: string | number;
+ profileComplete: boolean;
+};
From 80fc25e82362c010998788e7afa9a84fe898b031 Mon Sep 17 00:00:00 2001
From: Yanaphat Chatphokhinkun
Date: Sat, 12 Sep 2026 09:21:34 +0700
Subject: [PATCH 5/8] feat: extend multi-select base component
---
.../components/ui/multi-select.stories.tsx | 24 +++++++++++++
apps/web/src/components/ui/multi-select.tsx | 35 +++++++++++++------
2 files changed, 48 insertions(+), 11 deletions(-)
diff --git a/apps/web/src/components/ui/multi-select.stories.tsx b/apps/web/src/components/ui/multi-select.stories.tsx
index 7014c49..38b87ee 100644
--- a/apps/web/src/components/ui/multi-select.stories.tsx
+++ b/apps/web/src/components/ui/multi-select.stories.tsx
@@ -1,5 +1,6 @@
import { Fragment, type ReactNode } from "react";
import type { Meta, StoryObj } from "@storybook/nextjs-vite";
+import { expect, fn, userEvent, within } from "storybook/test";
import { MultiSelect } from "@/components/ui/multi-select";
const options = ["Category 1", "Category 2", "Category 3", "Category 4"];
@@ -51,6 +52,29 @@ export const WithError: Story = {
},
};
+export const SubjectOptions: Story = {
+ args: {
+ label: "Subjects you teach",
+ placeholder: "Select subjects",
+ options: [
+ { label: "Mathematics", value: "6f050b29-f070-49ae-b2ab-91d96a469f88" },
+ { label: "Physics", value: "9477ccf6-21e6-433d-b042-96dfba1056ac" },
+ ],
+ onValueChange: fn(),
+ },
+ play: async ({ args, canvasElement }) => {
+ const canvas = within(canvasElement);
+ await userEvent.click(canvas.getByRole("button", { name: /Subjects you teach/ }));
+
+ const body = within(canvasElement.ownerDocument.body);
+ await userEvent.click(await body.findByRole("menuitemcheckbox", { name: "Mathematics" }));
+
+ await expect(args.onValueChange).toHaveBeenLastCalledWith([
+ "6f050b29-f070-49ae-b2ab-91d96a469f88",
+ ]);
+ },
+};
+
export const AllStates: Story = {
parameters: {
layout: "fullscreen",
diff --git a/apps/web/src/components/ui/multi-select.tsx b/apps/web/src/components/ui/multi-select.tsx
index 99370d0..e63334e 100644
--- a/apps/web/src/components/ui/multi-select.tsx
+++ b/apps/web/src/components/ui/multi-select.tsx
@@ -1,3 +1,5 @@
+"use client";
+
import { useId, useState } from "react";
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
import { Check, ChevronDown } from "lucide-react";
@@ -5,10 +7,16 @@ import { cn } from "@/lib/utils";
const textSize = "text-sm leading-[23px] md:text-base md:leading-[26px]";
+export interface MultiSelectOption {
+ label: string;
+ value: string;
+ disabled?: boolean;
+}
+
export interface MultiSelectProps {
label?: string;
placeholder?: string;
- options: string[];
+ options: Array;
value?: string[];
defaultValue?: string[];
onValueChange?: (value: string[]) => void;
@@ -42,15 +50,20 @@ const MultiSelect = ({
}: MultiSelectProps) => {
const [internalValue, setInternalValue] = useState(defaultValue ?? []);
const selected = value ?? internalValue;
+ const normalizedOptions = options.map((option) =>
+ typeof option === "string" ? { label: option, value: option } : option
+ );
const labelId = useId();
const triggerId = useId();
const errorId = useId();
- const toggle = (option: string) => {
- const next = selected.includes(option)
- ? selected.filter((v) => v !== option)
- : [...selected, option];
+ const toggle = (option: MultiSelectOption) => {
+ if (disabled || option.disabled) return;
+
+ const next = selected.includes(option.value)
+ ? selected.filter((value) => value !== option.value)
+ : [...selected, option.value];
if (value === undefined) {
setInternalValue(next);
}
@@ -96,13 +109,13 @@ const MultiSelect = ({
sideOffset={4}
className="flex flex-col gap-1 border-hairline data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=top]:slide-in-from-bottom-2 w-[var(--radix-dropdown-menu-trigger-width,12.5rem)] rounded-lg border bg-white p-1.5 shadow-black"
>
- {options.map((option) => {
- const checked = selected.includes(option);
+ {normalizedOptions.map((option) => {
+ const checked = selected.includes(option.value);
return (
toggle(option)}
onSelect={(e) => e.preventDefault()}
className={cn(
@@ -122,7 +135,7 @@ const MultiSelect = ({
>
{checked && }
- {option}
+ {option.label}
);
})}
@@ -130,7 +143,7 @@ const MultiSelect = ({
{name &&
- selected.map((option) => )}
+ selected.map((value) => )}
{error && errorMessage && (
{errorMessage}
From 94085a6adab18a6ef3664d12b8d9b20e98fc23d0 Mon Sep 17 00:00:00 2001
From: Yanaphat Chatphokhinkun
Date: Sat, 12 Sep 2026 09:28:33 +0700
Subject: [PATCH 6/8] feat: add api for tutor selects subjects
---
apps/api/src/controller/user.controller.ts | 26 ++++--
apps/api/src/repository/subject.repository.ts | 11 +++
apps/api/src/repository/user.repository.ts | 20 ++++-
apps/api/src/router/user.router.ts | 25 +++++-
apps/api/src/service/user.service.ts | 86 +++++++++++++++----
5 files changed, 135 insertions(+), 33 deletions(-)
diff --git a/apps/api/src/controller/user.controller.ts b/apps/api/src/controller/user.controller.ts
index 540132b..c2ca9b6 100644
--- a/apps/api/src/controller/user.controller.ts
+++ b/apps/api/src/controller/user.controller.ts
@@ -38,12 +38,17 @@ const tutorProfileUpdateSchema = z
.object({
...editableProfileFields,
hourlyRate: tutorProfileFields.hourlyRate.optional(),
+ subjectIds: z
+ .array(z.string().uuid("Each subject ID must be a valid UUID"))
+ .refine((ids) => new Set(ids).size === ids.length, "Subject IDs must be unique")
+ .optional(),
})
.strict();
type ProfileData = z.infer | z.infer;
-type ProfileUpdateData =
- z.infer | z.infer;
+type ProfileUpdateRequest =
+ | { role: typeof Role.student; data: z.infer }
+ | { role: typeof Role.tutor; data: z.infer };
export const userController = {
async submitProfile(req: AuthRequest, res: Response, next: NextFunction) {
@@ -95,20 +100,23 @@ export const userController = {
throw new UnauthorizedError("User not found in request");
}
- let role: Role;
- let validatedData: ProfileUpdateData;
+ let profileUpdate: ProfileUpdateRequest;
if (user.role === Role.student) {
- role = Role.student;
- validatedData = studentProfileUpdateSchema.parse(req.body);
+ profileUpdate = {
+ role: Role.student,
+ data: studentProfileUpdateSchema.parse(req.body),
+ };
} else if (user.role === Role.tutor) {
- role = Role.tutor;
- validatedData = tutorProfileUpdateSchema.parse(req.body);
+ profileUpdate = {
+ role: Role.tutor,
+ data: tutorProfileUpdateSchema.parse(req.body),
+ };
} else {
throw new UnauthorizedError("Invalid authentication role");
}
- const updatedUser = await userService.updateProfile(user.id, role, validatedData);
+ const updatedUser = await userService.updateProfile(user.id, profileUpdate);
res.status(200).json(successResponse(updatedUser, "Profile updated successfully"));
} catch (error) {
next(error);
diff --git a/apps/api/src/repository/subject.repository.ts b/apps/api/src/repository/subject.repository.ts
index f720c11..e0a62c4 100644
--- a/apps/api/src/repository/subject.repository.ts
+++ b/apps/api/src/repository/subject.repository.ts
@@ -5,4 +5,15 @@ export const subjectRepository = {
async findAll(): Promise {
return prisma.subject.findMany({ orderBy: { name: "asc" } });
},
+
+ async findByIds(subjectIds: string[]): Promise[]> {
+ if (subjectIds.length === 0) {
+ return [];
+ }
+
+ return prisma.subject.findMany({
+ where: { id: { in: subjectIds } },
+ select: { id: true },
+ });
+ },
};
diff --git a/apps/api/src/repository/user.repository.ts b/apps/api/src/repository/user.repository.ts
index c00b4ba..9b5a7c6 100644
--- a/apps/api/src/repository/user.repository.ts
+++ b/apps/api/src/repository/user.repository.ts
@@ -74,11 +74,25 @@ export const userRepository = {
});
},
- async updateExistingProfile(userId: string, data: ExistingProfileUpdate): Promise {
+ async updateExistingProfile(
+ userId: string,
+ data: ExistingProfileUpdate,
+ subjectIds?: string[]
+ ): Promise {
return prisma.user.update({
where: { id: userId },
- data,
- select: publicUserSelect,
+ data: {
+ ...data,
+ ...(subjectIds !== undefined && {
+ tutorSubjects: {
+ deleteMany: {},
+ create: subjectIds.map((subjectId) => ({
+ subject: { connect: { id: subjectId } },
+ })),
+ },
+ }),
+ },
+ select: profileSelect,
});
},
};
diff --git a/apps/api/src/router/user.router.ts b/apps/api/src/router/user.router.ts
index 456fad7..a491dad 100644
--- a/apps/api/src/router/user.router.ts
+++ b/apps/api/src/router/user.router.ts
@@ -113,6 +113,13 @@ import type { AuthRequest } from "../common/middleware/auth.middleware.js";
* exclusiveMinimum: 0
* description: Optional; retains its current value when omitted
* example: 750.0
+ * subjectIds:
+ * type: array
+ * uniqueItems: true
+ * description: Optional; omitted retains current subjects, an empty array removes all subjects, and a non-empty array replaces them.
+ * items:
+ * type: string
+ * format: uuid
*/
// Cast router to use AuthRequest
@@ -188,8 +195,22 @@ router.get("/me", authMiddleware, userController.getProfile);
* - $ref: '#/components/schemas/StudentProfileUpdate'
* - $ref: '#/components/schemas/TutorProfileUpdate'
* responses:
- * 200: { description: Profile updated successfully }
- * 400: { description: Missing, invalid, role-incompatible, or unknown request fields }
+ * 200:
+ * description: Profile updated successfully
+ * content:
+ * application/json:
+ * schema:
+ * type: object
+ * properties:
+ * success: { type: boolean, example: true }
+ * message: { type: string, example: Profile updated successfully }
+ * data:
+ * allOf:
+ * - $ref: '#/components/schemas/UserProfile'
+ * - type: object
+ * properties:
+ * profileComplete: { type: boolean, example: true }
+ * 400: { description: Missing, invalid, role-incompatible, duplicate, malformed, or unknown request fields or subject IDs }
* 401: { description: Missing or invalid authentication, deleted user, or role mismatch }
* 409: { description: Initial profile completion is required }
* 500: { description: Internal server error }
diff --git a/apps/api/src/service/user.service.ts b/apps/api/src/service/user.service.ts
index e420c2f..fad0901 100644
--- a/apps/api/src/service/user.service.ts
+++ b/apps/api/src/service/user.service.ts
@@ -1,5 +1,10 @@
import { Role, User, Prisma } from "@prisma/client";
-import { userRepository } from "../repository/user.repository.js";
+import {
+ userRepository,
+ type ExistingProfileUpdate,
+ type UserProfileRow,
+} from "../repository/user.repository.js";
+import { subjectRepository } from "../repository/subject.repository.js";
import {
NotFoundError,
BadRequestError,
@@ -13,12 +18,48 @@ type ProfileData = {
hourlyRate?: number;
};
-type ProfileUpdateData = ProfileData & {
+type CommonProfileUpdateData = {
name: string;
profileUrl: string | null;
bio: string | null;
};
+type StudentProfileUpdateData = CommonProfileUpdateData & {
+ gradeLevel?: string;
+ goals?: string;
+ hourlyRate?: never;
+ subjectIds?: never;
+};
+
+type TutorProfileUpdateData = CommonProfileUpdateData & {
+ hourlyRate?: number;
+ subjectIds?: string[];
+ gradeLevel?: never;
+ goals?: never;
+};
+
+type ProfileUpdateRequest =
+ | { role: typeof Role.student; data: StudentProfileUpdateData }
+ | { role: typeof Role.tutor; data: TutorProfileUpdateData };
+
+function mapProfile(user: UserProfileRow) {
+ return {
+ id: user.id,
+ name: user.name,
+ email: user.email,
+ role: user.role,
+ profileUrl: user.profileUrl,
+ bio: user.bio,
+ hourlyRate: user.hourlyRate === null ? null : Number(user.hourlyRate),
+ gradeLevel: user.gradeLevel,
+ goals: user.goals,
+ walletBalance: Number(user.walletBalance),
+ profileComplete: user.profileComplete,
+ createdAt: user.createdAt,
+ subjects: user.tutorSubjects.map((tutorSubject) => tutorSubject.subject),
+ };
+}
+
export const userService = {
async completeProfile(userId: string, role: Role, data: ProfileData) {
let updateData: Partial = { profileComplete: true };
@@ -46,23 +87,11 @@ export const userService = {
throw new NotFoundError("User not found");
}
- return {
- id: user.id,
- name: user.name,
- email: user.email,
- role: user.role,
- profileUrl: user.profileUrl,
- bio: user.bio,
- hourlyRate: user.hourlyRate ? Number(user.hourlyRate) : null,
- gradeLevel: user.gradeLevel,
- goals: user.goals,
- walletBalance: Number(user.walletBalance),
- createdAt: user.createdAt,
- subjects: user.tutorSubjects.map((ts) => ts.subject),
- };
+ return mapProfile(user);
},
- async updateProfile(userId: string, role: Role, data: ProfileUpdateData) {
+ async updateProfile(userId: string, profileUpdate: ProfileUpdateRequest) {
+ const { role, data } = profileUpdate;
const profileOwner = await userRepository.findProfileOwnerById(userId);
if (!profileOwner) {
@@ -83,7 +112,8 @@ export const userService = {
bio: data.bio,
};
- let updateData;
+ let updateData: ExistingProfileUpdate;
+ let subjectIds: string[] | undefined;
if (role === Role.student) {
updateData = {
...commonData,
@@ -97,12 +127,30 @@ export const userService = {
hourlyRate: new Prisma.Decimal(data.hourlyRate),
}),
};
+
+ subjectIds = data.subjectIds;
+ if (subjectIds !== undefined) {
+ const existingSubjects = await subjectRepository.findByIds(subjectIds);
+ const existingSubjectIds = new Set(existingSubjects.map((subject) => subject.id));
+ const unknownSubjectIds = subjectIds.filter(
+ (subjectId) => !existingSubjectIds.has(subjectId)
+ );
+
+ if (unknownSubjectIds.length > 0) {
+ throw new BadRequestError(`Unknown subject IDs: ${unknownSubjectIds.join(", ")}`);
+ }
+ }
} else {
throw new UnauthorizedError("Invalid authentication role");
}
try {
- return await userRepository.updateExistingProfile(userId, updateData);
+ const updatedUser = await userRepository.updateExistingProfile(
+ userId,
+ updateData,
+ subjectIds
+ );
+ return mapProfile(updatedUser);
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2025") {
throw new UnauthorizedError("Authenticated user no longer exists");
From 811e4cb0d492797d789435d168257c936571f587 Mon Sep 17 00:00:00 2001
From: Yanaphat Chatphokhinkun
Date: Sat, 12 Sep 2026 09:28:52 +0700
Subject: [PATCH 7/8] feat: integrate frontend
---
.../components/profile/profile-edit-form.tsx | 98 +++++++++++++------
apps/web/src/types/user.ts | 9 +-
2 files changed, 69 insertions(+), 38 deletions(-)
diff --git a/apps/web/src/components/profile/profile-edit-form.tsx b/apps/web/src/components/profile/profile-edit-form.tsx
index 15f3e3e..02bc554 100644
--- a/apps/web/src/components/profile/profile-edit-form.tsx
+++ b/apps/web/src/components/profile/profile-edit-form.tsx
@@ -8,11 +8,13 @@ import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
+import { MultiSelect } from "@/components/ui/multi-select";
import { Select } from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { ApiError, apiClient } from "@/lib/api-client";
import { clearSession, getToken, saveSession } from "@/lib/auth-storage";
import { GRADE_LEVELS } from "@/lib/grade-levels";
+import type { Subject } from "@/types/subject";
import type { ProfileUpdateResponse, UserProfile } from "@/types/user";
type Fields = { gradeLevel: string; goals: string; hourlyRate: string };
@@ -25,12 +27,18 @@ function fieldsFromProfile(profile: UserProfile): Fields {
};
}
+function haveSameSubjectIds(left: string[], right: string[]) {
+ return JSON.stringify([...left].sort()) === JSON.stringify([...right].sort());
+}
+
export function ProfileEditForm() {
const router = useRouter();
const [profile, setProfile] = useState(null);
const [name, setName] = useState("");
const [about, setAbout] = useState("");
const [fields, setFields] = useState({ gradeLevel: "", goals: "", hourlyRate: "" });
+ const [availableSubjects, setAvailableSubjects] = useState([]);
+ const [selectedSubjectIds, setSelectedSubjectIds] = useState([]);
const [touched, setTouched] = useState>>({});
const [loading, setLoading] = useState(true);
const [needsLogin, setNeedsLogin] = useState(false);
@@ -50,16 +58,24 @@ export function ProfileEditForm() {
}
setLoading(true);
setLoadError("");
- apiClient
- .get("/users/me")
- .then((current) => {
+ setProfile(null);
+ setAvailableSubjects([]);
+ setSelectedSubjectIds([]);
+
+ async function loadProfile() {
+ try {
+ const current = await apiClient.get("/users/me");
+ const subjects =
+ current.role === "tutor" ? await apiClient.get("/subjects") : [];
+
if (!active) return;
setProfile(current);
setName(current.name ?? "");
setAbout(current.bio ?? "");
setFields(fieldsFromProfile(current));
- })
- .catch((error: unknown) => {
+ setAvailableSubjects(subjects);
+ setSelectedSubjectIds(current.subjects.map((subject) => subject.id));
+ } catch (error) {
if (active) {
if (error instanceof ApiError && error.status === 401) {
clearSession();
@@ -68,10 +84,12 @@ export function ProfileEditForm() {
}
setLoadError(error instanceof Error ? error.message : "Unable to load your profile.");
}
- })
- .finally(() => {
+ } finally {
if (active) setLoading(false);
- });
+ }
+ }
+
+ void loadProfile();
return () => {
active = false;
};
@@ -100,7 +118,11 @@ export function ProfileEditForm() {
(isStudent
? fields.gradeLevel.trim() !== (profile.gradeLevel ?? "").trim() ||
fields.goals.trim() !== (profile.goals ?? "").trim()
- : fields.hourlyRate.trim() !== "" && rate !== profile.hourlyRate))
+ : (fields.hourlyRate.trim() !== "" && rate !== profile.hourlyRate) ||
+ !haveSameSubjectIds(
+ selectedSubjectIds,
+ profile.subjects.map((subject) => subject.id)
+ )))
);
function edit(field: keyof Fields, value: string) {
@@ -127,19 +149,14 @@ export function ProfileEditForm() {
bio: about.trim() || null,
...(isStudent
? { gradeLevel: fields.gradeLevel.trim(), goals: fields.goals.trim() }
- : { hourlyRate: rate }),
+ : { hourlyRate: rate, subjectIds: selectedSubjectIds }),
});
- // 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),
- };
+ const current: UserProfile = updated;
setProfile(current);
setName(current.name ?? "");
setAbout(current.bio ?? "");
setFields(fieldsFromProfile(current));
+ setSelectedSubjectIds(current.subjects.map((subject) => subject.id));
setTouched({});
const token = getToken();
if (token) {
@@ -306,20 +323,39 @@ export function ProfileEditForm() {
/>
>
) : (
- edit("hourlyRate", event.target.value)}
- onBlur={() => touch("hourlyRate")}
- error={Boolean(touched.hourlyRate && errors.hourlyRate)}
- errorMessage={errors.hourlyRate}
- className="h-10 rounded-xl"
- />
+ <>
+ edit("hourlyRate", event.target.value)}
+ onBlur={() => touch("hourlyRate")}
+ error={Boolean(touched.hourlyRate && errors.hourlyRate)}
+ errorMessage={errors.hourlyRate}
+ className="h-10 rounded-xl"
+ />
+ ({
+ label: subject.name,
+ value: subject.id,
+ }))}
+ value={selectedSubjectIds}
+ onValueChange={(subjectIds) => {
+ setSelectedSubjectIds(subjectIds);
+ setSaved(false);
+ setSaveError("");
+ }}
+ disabled={saving}
+ className="w-full gap-2 sm:col-start-2 sm:row-start-3 [&>button]:h-10 [&>button]:rounded-xl [&>button]:px-4 [&>button]:text-sm [&>button:focus-visible]:ring-4 [&>button:focus-visible]:ring-primary/10"
+ />
+ >
)}
& {
- hourlyRate: string | number | null;
- walletBalance: string | number;
+/** Shape of a successful `PUT /users/profile` response. */
+export type ProfileUpdateResponse = UserProfile & {
profileComplete: boolean;
};
From 3fb865b084d4004fdb97e2854230ca4114730204 Mon Sep 17 00:00:00 2001
From: note
Date: Sat, 12 Sep 2026 09:58:10 +0700
Subject: [PATCH 8/8] fix: grade level
---
.../components/profile/profile-completion-form.tsx | 12 +++++++++++-
.../web/src/components/profile/profile-edit-form.tsx | 2 +-
apps/web/src/lib/grade-levels.ts | 10 ----------
3 files changed, 12 insertions(+), 12 deletions(-)
delete mode 100644 apps/web/src/lib/grade-levels.ts
diff --git a/apps/web/src/components/profile/profile-completion-form.tsx b/apps/web/src/components/profile/profile-completion-form.tsx
index 34849bc..0f7aeef 100644
--- a/apps/web/src/components/profile/profile-completion-form.tsx
+++ b/apps/web/src/components/profile/profile-completion-form.tsx
@@ -10,7 +10,6 @@ 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 { GRADE_LEVELS } from "@/lib/grade-levels";
type UserRole = "student" | "tutor";
@@ -18,6 +17,17 @@ type ProfileCompletionFormProps = {
role: UserRole;
};
+export const GRADE_LEVELS = [
+ "Grade 7",
+ "Grade 8",
+ "Grade 9",
+ "Grade 10",
+ "Grade 11",
+ "Grade 12",
+ "University",
+ "Other",
+];
+
export function ProfileCompletionForm({ role }: ProfileCompletionFormProps) {
const router = useRouter();
const photoInputId = useId();
diff --git a/apps/web/src/components/profile/profile-edit-form.tsx b/apps/web/src/components/profile/profile-edit-form.tsx
index 15f3e3e..53edf68 100644
--- a/apps/web/src/components/profile/profile-edit-form.tsx
+++ b/apps/web/src/components/profile/profile-edit-form.tsx
@@ -12,7 +12,7 @@ import { Select } from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { ApiError, apiClient } from "@/lib/api-client";
import { clearSession, getToken, saveSession } from "@/lib/auth-storage";
-import { GRADE_LEVELS } from "@/lib/grade-levels";
+import { GRADE_LEVELS } from "@/components/profile/profile-completion-form";
import type { ProfileUpdateResponse, UserProfile } from "@/types/user";
type Fields = { gradeLevel: string; goals: string; hourlyRate: string };
diff --git a/apps/web/src/lib/grade-levels.ts b/apps/web/src/lib/grade-levels.ts
deleted file mode 100644
index 2300e7c..0000000
--- a/apps/web/src/lib/grade-levels.ts
+++ /dev/null
@@ -1,10 +0,0 @@
-export const GRADE_LEVELS = [
- "Grade 7",
- "Grade 8",
- "Grade 9",
- "Grade 10",
- "Grade 11",
- "Grade 12",
- "University",
- "Other",
-];