From 5edfee2f6c8802d0a6b7dd0a98197a087c3ba29a Mon Sep 17 00:00:00 2001 From: amywng <147568742+amywng@users.noreply.github.com> Date: Mon, 16 Mar 2026 09:16:24 -0400 Subject: [PATCH 1/3] add profile page --- .../src/users/users.controller.spec.ts | 15 +- apps/backend/src/users/users.controller.ts | 6 +- apps/frontend/src/api/apiClient.ts | 19 +- apps/frontend/src/app.tsx | 9 + .../components/profile/profileAccountInfo.tsx | 172 ++++++++++++++++++ .../src/components/profile/profileLayout.tsx | 111 +++++++++++ .../components/profile/profileLeftPanel.tsx | 64 +++++++ .../src/containers/adminOrderManagement.tsx | 10 +- apps/frontend/src/containers/homepage.tsx | 5 + apps/frontend/src/containers/profilePage.tsx | 132 ++++++++++++++ .../containers/volunteerAssignedPantries.tsx | 6 +- .../src/containers/volunteerManagement.tsx | 4 +- apps/frontend/src/theme.ts | 3 +- apps/frontend/src/types/types.ts | 4 + apps/frontend/src/utils/utils.ts | 3 + 15 files changed, 535 insertions(+), 28 deletions(-) create mode 100644 apps/frontend/src/components/profile/profileAccountInfo.tsx create mode 100644 apps/frontend/src/components/profile/profileLayout.tsx create mode 100644 apps/frontend/src/components/profile/profileLeftPanel.tsx create mode 100644 apps/frontend/src/containers/profilePage.tsx diff --git a/apps/backend/src/users/users.controller.spec.ts b/apps/backend/src/users/users.controller.spec.ts index 0a2b0433..de9fdb5e 100644 --- a/apps/backend/src/users/users.controller.spec.ts +++ b/apps/backend/src/users/users.controller.spec.ts @@ -47,13 +47,18 @@ describe('UsersController', () => { expect(controller).toBeDefined(); }); - describe('GET /my-id', () => { - it('should return the current user id', () => { - const req = { user: { id: 1 } } as AuthenticatedRequest; + describe('GET /me', () => { + it('should return the current user', async () => { + const req = { + user: { + id: 1, + }, + } as AuthenticatedRequest; - const result = controller.getCurrentUserId(req); + mockUserService.findOne.mockResolvedValueOnce(mockUser1 as User); + const result = await controller.getCurrentUser(req); - expect(result).toBe(1); + expect(result).toEqual(mockUser1); }); }); diff --git a/apps/backend/src/users/users.controller.ts b/apps/backend/src/users/users.controller.ts index 1a9963e5..55bad182 100644 --- a/apps/backend/src/users/users.controller.ts +++ b/apps/backend/src/users/users.controller.ts @@ -23,9 +23,9 @@ export class UsersController { constructor(private usersService: UsersService) {} @UseGuards(JwtAuthGuard) - @Get('/my-id') - getCurrentUserId(@Req() req: AuthenticatedRequest): number { - return req.user.id; + @Get('/me') + getCurrentUser(@Req() req: AuthenticatedRequest): Promise { + return this.usersService.findOne(req.user.id); } @Get('/:id') diff --git a/apps/frontend/src/api/apiClient.ts b/apps/frontend/src/api/apiClient.ts index 37e4ed11..c92cb45d 100644 --- a/apps/frontend/src/api/apiClient.ts +++ b/apps/frontend/src/api/apiClient.ts @@ -25,6 +25,7 @@ import { Assignments, FoodRequestSummaryDto, PantryWithUser, + UpdateProfileFields, } from 'types/types'; const defaultBaseUrl = @@ -153,10 +154,6 @@ export class ApiClient { return this.axiosInstance.post(`/api/users`, data); } - public async getPantrySSFRep(pantryId: number): Promise { - return this.get(`/api/pantries/${pantryId}/ssf-contact`) as Promise; - } - public async getAllPendingPantries(): Promise { return this.axiosInstance .get('/api/pantries/pending') @@ -195,11 +192,11 @@ export class ApiClient { return this.get(`/api/volunteers/${userId}/pantries`) as Promise; } - public async updateUserVolunteerRole( + public async updateUser( userId: number, - body: { role: string }, - ): Promise { - await this.axiosInstance.put(`/api/users/${userId}/role`, body); + fields: UpdateProfileFields, + ): Promise { + return this.axiosInstance.patch(`/api/users/${userId}`, fields); } public async getFoodRequest(requestId: number): Promise { @@ -324,9 +321,9 @@ export class ApiClient { return data as number; } - public async getMyId(): Promise { - const data = await this.get('/api/users/my-id'); - return data as number; + public async getMe(): Promise { + const data = await this.get('/api/users/me'); + return data as User; } } diff --git a/apps/frontend/src/app.tsx b/apps/frontend/src/app.tsx index d679ca6e..5ab7a281 100644 --- a/apps/frontend/src/app.tsx +++ b/apps/frontend/src/app.tsx @@ -29,6 +29,7 @@ import { Authenticator } from '@aws-amplify/ui-react'; import FoodManufacturerApplication from '@containers/foodManufacturerApplication'; import { submitManufacturerApplicationForm } from '@components/forms/manufacturerApplicationForm'; import AssignedPantries from '@containers/volunteerAssignedPantries'; +import ProfilePage from '@containers/profilePage'; Amplify.configure(CognitoAuthConfig); @@ -186,6 +187,14 @@ const router = createBrowserRouter([ ), }, + { + path: '/profile', + element: ( + + + + ), + }, { path: '/confirm-delivery', action: submitDeliveryConfirmationFormModal, diff --git a/apps/frontend/src/components/profile/profileAccountInfo.tsx b/apps/frontend/src/components/profile/profileAccountInfo.tsx new file mode 100644 index 00000000..f1b90d80 --- /dev/null +++ b/apps/frontend/src/components/profile/profileAccountInfo.tsx @@ -0,0 +1,172 @@ +import React, { useEffect, useState } from 'react'; +import { Box, Text, SimpleGrid, Input, Button, HStack } from '@chakra-ui/react'; +import { UpdateProfileFields } from 'types/types'; + +interface AccountInfoSectionProps { + firstName: string; + lastName: string; + email: string; + phoneNumber: string; + onSave: (fields: UpdateProfileFields) => Promise; + isEditing: boolean; + onEditToggle: () => void; +} + +interface ProfileFieldProps { + label: string; + value: string; + name: string; + isEditing: boolean; + readOnly?: boolean; + onChange: (e: React.ChangeEvent) => void; +} + +const headerStyles = { + fontSize: '14px', + fontWeight: 600, + fontFamily: 'inter', + color: 'neutral.800', +}; + +const ProfileField: React.FC = ({ + label, + value, + name, + isEditing, + readOnly = false, + onChange, +}) => ( + + + {label} + + {isEditing && !readOnly ? ( + + ) : ( + + {value} + + )} + +); + +const ProfileAccountInfo: React.FC = ({ + firstName, + lastName, + email, + phoneNumber, + onSave, + isEditing, + onEditToggle, +}) => { + const [isSaving, setIsSaving] = useState(false); + const [form, setForm] = useState({ firstName, lastName, phoneNumber }); + + useEffect(() => { + setForm({ firstName, lastName, phoneNumber }); + }, [firstName, lastName, phoneNumber]); + + const handleChange = (e: React.ChangeEvent) => + setForm((prev) => ({ ...prev, [e.target.name]: e.target.value })); + + const handleCancel = () => { + setForm({ firstName, lastName, phoneNumber }); + onEditToggle(); + }; + + const handleSave = async () => { + const changed: UpdateProfileFields = {}; + if (form.firstName !== firstName) changed.firstName = form.firstName; + if (form.lastName !== lastName) changed.lastName = form.lastName; + if (form.phoneNumber !== phoneNumber) changed.phone = form.phoneNumber; + + if (Object.keys(changed).length === 0) { + onEditToggle(); + return; + } + + setIsSaving(true); + await onSave(changed); + setIsSaving(false); + onEditToggle(); + }; + + return ( + + + + + + + + + {isEditing && ( + + + + + )} + + ); +}; + +export default ProfileAccountInfo; diff --git a/apps/frontend/src/components/profile/profileLayout.tsx b/apps/frontend/src/components/profile/profileLayout.tsx new file mode 100644 index 00000000..358782e2 --- /dev/null +++ b/apps/frontend/src/components/profile/profileLayout.tsx @@ -0,0 +1,111 @@ +import React, { useState } from 'react'; +import { Box, Tabs, HStack, Text } from '@chakra-ui/react'; +import { Pencil } from 'lucide-react'; + +interface Tab { + label: string; + content: React.ReactNode; +} + +interface ProfileLayoutProps { + leftPanel: React.ReactNode; + tabs: Tab[]; + isEditing: boolean; + onEditToggle: () => void; +} + +const EditButton: React.FC<{ + isEditing: boolean; + onEditToggle: () => void; +}> = ({ isEditing, onEditToggle }) => ( + + + + {isEditing ? 'Editing' : 'Edit'} + + +); + +const ProfileLayout: React.FC = ({ + leftPanel, + tabs, + isEditing, + onEditToggle, +}) => { + const [activeTab, setActiveTab] = useState(tabs[0].label); + + return ( + + + {leftPanel} + + + + + {tabs.length > 1 ? ( + setActiveTab(e.value)} + variant="line" + > + + + {tabs.map((tab) => ( + + {tab.label} + + ))} + + + + + {tabs.map((tab) => ( + + {tab.content} + + ))} + + ) : ( + <> + + + Account Details + + + + {tabs[0]?.content} + + )} + + + + ); +}; + +export default ProfileLayout; diff --git a/apps/frontend/src/components/profile/profileLeftPanel.tsx b/apps/frontend/src/components/profile/profileLeftPanel.tsx new file mode 100644 index 00000000..c9fbad53 --- /dev/null +++ b/apps/frontend/src/components/profile/profileLeftPanel.tsx @@ -0,0 +1,64 @@ +import React from 'react'; +import { VStack, Text, Button, Box } from '@chakra-ui/react'; +import { LockKeyhole } from 'lucide-react'; + +interface ProfileLeftPanelProps { + name: string; + roleLabel: string; + initials: string; + avatarBg: string; +} + +const ProfileLeftPanel: React.FC = ({ + name, + roleLabel, + initials, + avatarBg, +}) => ( + + + + {initials} + + + + + + {name} + + + + {roleLabel} + + + + + +); + +export default ProfileLeftPanel; diff --git a/apps/frontend/src/containers/adminOrderManagement.tsx b/apps/frontend/src/containers/adminOrderManagement.tsx index ebf5a866..b49fa7eb 100644 --- a/apps/frontend/src/containers/adminOrderManagement.tsx +++ b/apps/frontend/src/containers/adminOrderManagement.tsx @@ -20,7 +20,7 @@ import { CircleCheck, Search, } from 'lucide-react'; -import { capitalize, formatDate } from '@utils/utils'; +import { capitalize, formatDate, getInitials } from '@utils/utils'; import ApiClient from '@api/apiClient'; import { OrderStatus, OrderSummary } from '../types/types'; import OrderDetailsModal from '@components/forms/orderDetailsModal'; @@ -95,7 +95,7 @@ const AdminOrderManagement: React.FC = () => { const MAX_PER_STATUS = 5; - const ASSIGNEE_COLORS = ['yellow.ssf', 'red', 'cyan', 'blue.ssf']; + const ASSIGNEE_COLORS = ['yellow.ssf', 'red', 'teal.ssf', 'blue.ssf']; useEffect(() => { // Fetch all orders on component mount and sorts them into their appropriate status lists @@ -684,8 +684,10 @@ const OrderStatusSection: React.FC = ({ p={2} > {/* TODO: Change logic later to only get one volunteer */} - {volunteers[0].firstName.charAt(0).toUpperCase()} - {volunteers[0].lastName.charAt(0).toUpperCase()} + {getInitials( + volunteers[0].firstName, + volunteers[0].lastName, + )} ) : ( No Assignees diff --git a/apps/frontend/src/containers/homepage.tsx b/apps/frontend/src/containers/homepage.tsx index d37eee02..5f2d52e9 100644 --- a/apps/frontend/src/containers/homepage.tsx +++ b/apps/frontend/src/containers/homepage.tsx @@ -23,6 +23,11 @@ const Homepage: React.FC = () => { Site Navigation + + + Profile View + + Pantry View diff --git a/apps/frontend/src/containers/profilePage.tsx b/apps/frontend/src/containers/profilePage.tsx new file mode 100644 index 00000000..18fc42d4 --- /dev/null +++ b/apps/frontend/src/containers/profilePage.tsx @@ -0,0 +1,132 @@ +import React, { useEffect, useState } from 'react'; +import { Box, Center, Heading, Spinner, Text } from '@chakra-ui/react'; +import ApiClient from '../api/apiClient'; +import { Role, UpdateProfileFields, User } from '../types/types'; +import ProfileLeftPanel from '@components/profile/profileLeftPanel'; +import { getInitials } from '@utils/utils'; +import ProfileAccountInfo from '@components/profile/profileAccountInfo'; +import ProfileLayout from '@components/profile/profileLayout'; +import { useAlert } from '../hooks/alert'; +import { FloatingAlert } from '@components/floatingAlert'; + +const ROLE_CONFIG: Record = { + [Role.ADMIN]: { label: 'Admin', avatarBg: 'yellow.ssf' }, + [Role.VOLUNTEER]: { label: 'Volunteer', avatarBg: 'red' }, + [Role.PANTRY]: { label: 'Pantry', avatarBg: 'blue.400' }, + [Role.FOODMANUFACTURER]: { label: 'Food Manufacturer', avatarBg: 'teal.ssf' }, +}; + +const ApplicationTabPlaceholder: React.FC = () => ( +
+ Application details coming soon. +
+); + +const ProfilePage: React.FC = () => { + const [profile, setProfile] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [isEditing, setIsEditing] = useState(false); + const [alertState, setAlertMessage] = useAlert(); + + useEffect(() => { + const fetchProfile = async () => { + try { + const user: User = await ApiClient.getMe(); + setProfile(user); + } catch { + setAlertMessage('Authentication error. Please log in and try again.'); + } finally { + setIsLoading(false); + } + }; + fetchProfile(); + }, [setAlertMessage]); + + const handleSave = async (fields: UpdateProfileFields) => { + if (!profile) return; + try { + const updated: User = await ApiClient.updateUser(profile.id, fields); + setProfile(updated); + } catch { + setAlertMessage('Profile unable to be edited. Please try again later.'); + } + }; + + if (isLoading) { + return ( +
+ +
+ ); + } + + if (!profile) { + return ( +
+ Profile not found. +
+ ); + } + + const { firstName, lastName, email, phone } = profile; + const role = profile.role as Role; + const config = ROLE_CONFIG[role]; + + const leftPanel = ( + + ); + + const accountTab = { + label: 'Account', + showEdit: true, + content: ( + setIsEditing((e) => !e)} + /> + ), + }; + + // TODO: add application tab content + const tabs = + role === Role.PANTRY || role === Role.FOODMANUFACTURER + ? [ + accountTab, + { label: 'Application', content: }, + ] + : [accountTab]; + + return ( + + {alertState && ( + + )} + + Profile + + setIsEditing((e) => !e)} + /> + + ); +}; + +export default ProfilePage; diff --git a/apps/frontend/src/containers/volunteerAssignedPantries.tsx b/apps/frontend/src/containers/volunteerAssignedPantries.tsx index 7190d718..433fc838 100644 --- a/apps/frontend/src/containers/volunteerAssignedPantries.tsx +++ b/apps/frontend/src/containers/volunteerAssignedPantries.tsx @@ -12,7 +12,7 @@ import { Input, } from '@chakra-ui/react'; import ApiClient from '@api/apiClient'; -import { Pantry } from 'types/types'; +import { Pantry, User } from 'types/types'; import { RefrigeratedDonation } from '../types/pantryEnums'; import { FloatingAlert } from '@components/floatingAlert'; import { useNavigate } from 'react-router-dom'; @@ -31,9 +31,11 @@ const AssignedPantries: React.FC = () => { useEffect(() => { const fetchAssignedPantries = async () => { + let user: User; let userId: number; try { - userId = await ApiClient.getMyId(); + user = await ApiClient.getMe(); + userId = user.id; } catch { setAlertMessage('Authentication error. Please log in and try again.'); setIsLoading(false); diff --git a/apps/frontend/src/containers/volunteerManagement.tsx b/apps/frontend/src/containers/volunteerManagement.tsx index a597f83c..58022a20 100644 --- a/apps/frontend/src/containers/volunteerManagement.tsx +++ b/apps/frontend/src/containers/volunteerManagement.tsx @@ -18,6 +18,7 @@ import ApiClient from '@api/apiClient'; import NewVolunteerModal from '@components/forms/addNewVolunteerModal'; import { FloatingAlert } from '@components/floatingAlert'; import { useAlert } from '../hooks/alert'; +import { getInitials } from '@utils/utils'; const VolunteerManagement: React.FC = () => { const [currentPage, setCurrentPage] = useState(1); @@ -162,8 +163,7 @@ const VolunteerManagement: React.FC = () => { color="white" p={2} > - {volunteer.firstName.charAt(0).toUpperCase()} - {volunteer.lastName.charAt(0).toUpperCase()} + {getInitials(volunteer.firstName, volunteer.lastName)}
{volunteer.firstName} {volunteer.lastName} diff --git a/apps/frontend/src/theme.ts b/apps/frontend/src/theme.ts index 0b320004..97b11a9d 100644 --- a/apps/frontend/src/theme.ts +++ b/apps/frontend/src/theme.ts @@ -75,6 +75,7 @@ const customConfig = defineConfig({ hover: { value: '#213C4A' }, 100: { value: '#bee3f8' }, 200: { value: '#D5DCDF' }, + 400: { value: '#AAB8BF' }, }, red: { value: '#CC3538' }, yellow: { @@ -82,7 +83,6 @@ const customConfig = defineConfig({ hover: { value: '#9C5D00' }, 200: { value: '#FEECD1' }, }, - cyan: { value: '#2795A5' }, neutral: { 50: { value: '#FAFAFA' }, 100: { value: '#E7E7E7' }, @@ -99,6 +99,7 @@ const customConfig = defineConfig({ dark: { value: '#111' }, }, teal: { + ssf: { value: '#2795A5' }, 100: { value: '#E9F4F6' }, 200: { value: '#D4EAED' }, 400: { value: '#A9D5DB' }, diff --git a/apps/frontend/src/types/types.ts b/apps/frontend/src/types/types.ts index 40a9ff21..5bf9c244 100644 --- a/apps/frontend/src/types/types.ts +++ b/apps/frontend/src/types/types.ts @@ -170,6 +170,10 @@ export interface User { pantries?: Pantry[]; } +export type UpdateProfileFields = Partial< + Pick +>; + export interface UserDto { email: string; firstName: string; diff --git a/apps/frontend/src/utils/utils.ts b/apps/frontend/src/utils/utils.ts index 4e704f80..755ab20e 100644 --- a/apps/frontend/src/utils/utils.ts +++ b/apps/frontend/src/utils/utils.ts @@ -76,3 +76,6 @@ export const generateNextDonationDate = ( } return next.toISOString(); }; + +export const getInitials = (first: string, last: string) => + `${first[0] ?? ''}${last[0] ?? ''}`.toUpperCase(); From f36ea2b3ffe1c2dffccd7461423429944a67ab47 Mon Sep 17 00:00:00 2001 From: amywng <147568742+amywng@users.noreply.github.com> Date: Mon, 16 Mar 2026 09:24:10 -0400 Subject: [PATCH 2/3] fix padding --- apps/frontend/src/components/profile/profileLayout.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/frontend/src/components/profile/profileLayout.tsx b/apps/frontend/src/components/profile/profileLayout.tsx index 358782e2..e45b07a1 100644 --- a/apps/frontend/src/components/profile/profileLayout.tsx +++ b/apps/frontend/src/components/profile/profileLayout.tsx @@ -44,7 +44,7 @@ const ProfileLayout: React.FC = ({ const [activeTab, setActiveTab] = useState(tabs[0].label); return ( - + Date: Wed, 18 Mar 2026 10:48:46 -0400 Subject: [PATCH 3/3] comments --- apps/backend/src/users/users.controller.spec.ts | 6 +++--- apps/backend/src/users/users.controller.ts | 6 +++--- apps/frontend/src/components/profile/profileLayout.tsx | 4 ++-- apps/frontend/src/components/profile/profileLeftPanel.tsx | 3 ++- apps/frontend/src/containers/profilePage.tsx | 2 +- apps/frontend/src/theme.ts | 5 ++++- apps/frontend/src/types/types.ts | 2 +- 7 files changed, 16 insertions(+), 12 deletions(-) diff --git a/apps/backend/src/users/users.controller.spec.ts b/apps/backend/src/users/users.controller.spec.ts index de9fdb5e..c23c3cad 100644 --- a/apps/backend/src/users/users.controller.spec.ts +++ b/apps/backend/src/users/users.controller.spec.ts @@ -98,9 +98,9 @@ describe('UsersController', () => { }); it('should throw BadRequestException for invalid role', async () => { - await expect(controller.updateRole(1, 'invalid_role')).rejects.toThrow( - BadRequestException, - ); + await expect( + controller.updateRole(1, 'invalid_role' as Role), + ).rejects.toThrow(BadRequestException); expect(mockUserService.update).not.toHaveBeenCalled(); }); }); diff --git a/apps/backend/src/users/users.controller.ts b/apps/backend/src/users/users.controller.ts index 55bad182..c916bc04 100644 --- a/apps/backend/src/users/users.controller.ts +++ b/apps/backend/src/users/users.controller.ts @@ -41,12 +41,12 @@ export class UsersController { @Put('/:id/role') async updateRole( @Param('id', ParseIntPipe) id: number, - @Body('role') role: string, + @Body('role') role: Role, ): Promise { - if (!Object.values(Role).includes(role as Role)) { + if (!Object.values(Role).includes(role)) { throw new BadRequestException('Invalid role'); } - return this.usersService.update(id, { role: role as Role }); + return this.usersService.update(id, { role }); } @Post('/') diff --git a/apps/frontend/src/components/profile/profileLayout.tsx b/apps/frontend/src/components/profile/profileLayout.tsx index e45b07a1..cf65a78b 100644 --- a/apps/frontend/src/components/profile/profileLayout.tsx +++ b/apps/frontend/src/components/profile/profileLayout.tsx @@ -66,7 +66,7 @@ const ProfileLayout: React.FC = ({ onValueChange={(e: { value: string }) => setActiveTab(e.value)} variant="line" > - + {tabs.map((tab) => ( = ({ ) : ( <> - + Account Details diff --git a/apps/frontend/src/components/profile/profileLeftPanel.tsx b/apps/frontend/src/components/profile/profileLeftPanel.tsx index c9fbad53..5ff4d0a1 100644 --- a/apps/frontend/src/components/profile/profileLeftPanel.tsx +++ b/apps/frontend/src/components/profile/profileLeftPanel.tsx @@ -15,7 +15,7 @@ const ProfileLeftPanel: React.FC = ({ initials, avatarBg, }) => ( - + = ({ px={8} bg="red" size="sm" + color="white.core" onClick={() => { // TODO: add functionality }} diff --git a/apps/frontend/src/containers/profilePage.tsx b/apps/frontend/src/containers/profilePage.tsx index 18fc42d4..c178eb0b 100644 --- a/apps/frontend/src/containers/profilePage.tsx +++ b/apps/frontend/src/containers/profilePage.tsx @@ -69,7 +69,7 @@ const ProfilePage: React.FC = () => { } const { firstName, lastName, email, phone } = profile; - const role = profile.role as Role; + const role = profile.role; const config = ROLE_CONFIG[role]; const leftPanel = ( diff --git a/apps/frontend/src/theme.ts b/apps/frontend/src/theme.ts index 97b11a9d..900fb8bd 100644 --- a/apps/frontend/src/theme.ts +++ b/apps/frontend/src/theme.ts @@ -67,7 +67,10 @@ const customConfig = defineConfig({ textStyles, tokens: { colors: { - white: { value: '#fff' }, + white: { + value: '#fff', + core: { value: '#FEFEFE' }, + }, black: { value: '#000' }, blue: { ssf: { value: '#2B5061' }, diff --git a/apps/frontend/src/types/types.ts b/apps/frontend/src/types/types.ts index 5bf9c244..a06dbf84 100644 --- a/apps/frontend/src/types/types.ts +++ b/apps/frontend/src/types/types.ts @@ -162,7 +162,7 @@ export enum FoodType { export interface User { id: number; - role: string; + role: Role; firstName: string; lastName: string; email: string;