From 3c301ec2c42c12ed46b11fe6145e76e81aec8857 Mon Sep 17 00:00:00 2001 From: Asep Haryana Saputra <90584806+MythEclipse@users.noreply.github.com> Date: Mon, 18 Aug 2025 12:50:32 +0700 Subject: [PATCH] feat(auth): add Google authentication endpoints and hooks (#51) - Implemented `getGoogleAuthUrl` and `postGoogleCallback` in auth API. - Added corresponding hooks `useGoogleAuth` and `useGoogleCallback` for handling Google authentication. feat(api): enhance API structure with mentors and upload services - Created `mentors` and `upload` API modules with respective services for managing mentor details and file uploads. - Added session management utilities to handle authentication tokens in cookies. feat(users): expand user service with detailed user management - Enhanced user service to include detailed user information and update capabilities. - Added hooks for fetching and updating user data. feat(hooks): introduce hooks for mentors and upload functionalities - Added hooks for fetching mentor details and updating mentor information. - Implemented hooks for file upload, avatar upload, and CV upload with validation. refactor(types): organize and extend type definitions - Introduced new types for mentors and enhanced existing user types. - Re-exported user-related types for better accessibility. chore: update index files to include new services and hooks - Updated index files to export new services and hooks for mentors and uploads. --- apps/dimentorin/public/image/success.png | Bin 0 -> 2875 bytes .../src/app/(public)/profile/[id]/page.tsx | 166 +++ .../buttons/edit-section-button.tsx | 25 + .../profile/_components/buttons/index.ts | 2 + .../_components/buttons/modal-button.tsx | 76 ++ .../profile/_components/contexts/index.ts | 1 + .../_components/contexts/profile-context.tsx | 250 ++++ .../_components/guards/mentor-guard.tsx | 27 + .../app/(public)/profile/_components/index.ts | 17 + .../profile/_components/modals/cv-modal.tsx | 187 +++ .../_components/modals/description-modal.tsx | 99 ++ .../_components/modals/edit-profile-modal.tsx | 253 ++++ .../_components/modals/education-modal.tsx | 184 +++ .../_components/modals/experience-modal.tsx | 182 +++ .../profile/_components/modals/index.ts | 10 + .../_components/modals/languages-modal.tsx | 153 +++ .../_components/modals/notification-modal.tsx | 89 ++ .../modals/personal-info-modal.tsx | 137 ++ .../modals/profile-basic-info-modal.tsx | 105 ++ .../_components/modals/skills-modal.tsx | 168 +++ .../_components/modals/social-media-modal.tsx | 118 ++ .../profile/_components/profile/index.ts | 8 + .../profile/profile-form-handlers.ts | 120 ++ .../_components/profile/profile-form-hooks.ts | 253 ++++ .../_components/profile/profile-form-types.ts | 60 + .../_components/profile/profile-form.tsx | 251 ++++ .../_components/profile/profile-header.tsx | 115 ++ .../_components/profile/profile-info-new.tsx | 187 +++ .../_components/profile/profile-info.tsx | 190 +++ .../_components/profile/profile-sidebar.tsx | 239 ++++ .../_components/profile/profile-tabs.tsx | 245 ++++ .../sections/contact-info-section.tsx | 95 ++ .../sections/cv-resume-section.tsx | 105 ++ .../sections/description-section.tsx | 61 + .../sections/education-section.tsx | 85 ++ .../sections/experiences-section.tsx | 85 ++ .../profile/_components/sections/index.ts | 8 + .../sections/languages-section.tsx | 72 + .../sections/personal-info-section.tsx | 98 ++ .../_components/sections/skills-section.tsx | 75 ++ .../sections/social-media-section.tsx | 69 + .../_components/shared/file-uploader.tsx | 183 +++ .../profile/_components/shared/index.ts | 1 + .../_components/shared/section-wrapper.tsx | 26 + .../src/app/(public)/profile/page.tsx | 132 ++ .../src/app/_hooks/use-google-login.ts | 139 ++ .../src/app/auth/google-callback/page.tsx | 81 ++ .../src/app/auth/google-oauth-popup/page.tsx | 201 +++ apps/dimentorin/src/app/auth/login/page.tsx | 9 +- apps/dimentorin/src/middleware.ts | 10 +- apps/landing/src/openapi-types.ts | 1199 ++++++++++++++++- libs/service/src/api/auth/index.ts | 14 + libs/service/src/api/index.ts | 131 ++ libs/service/src/api/mentors/index.ts | 34 + libs/service/src/api/upload/index.ts | 63 + libs/service/src/api/users/index.ts | 90 +- libs/service/src/hooks/auth/index.ts | 27 +- libs/service/src/hooks/index.ts | 2 + libs/service/src/hooks/mentors/index.ts | 45 + libs/service/src/hooks/upload/index.ts | 39 + libs/service/src/hooks/users/index.ts | 45 +- libs/service/src/types/auth/index.ts | 7 +- libs/service/src/types/index.ts | 1 + libs/service/src/types/mentors/index.ts | 89 ++ libs/service/src/types/users/index.ts | 3 + 65 files changed, 7215 insertions(+), 26 deletions(-) create mode 100644 apps/dimentorin/public/image/success.png create mode 100644 apps/dimentorin/src/app/(public)/profile/[id]/page.tsx create mode 100644 apps/dimentorin/src/app/(public)/profile/_components/buttons/edit-section-button.tsx create mode 100644 apps/dimentorin/src/app/(public)/profile/_components/buttons/index.ts create mode 100644 apps/dimentorin/src/app/(public)/profile/_components/buttons/modal-button.tsx create mode 100644 apps/dimentorin/src/app/(public)/profile/_components/contexts/index.ts create mode 100644 apps/dimentorin/src/app/(public)/profile/_components/contexts/profile-context.tsx create mode 100644 apps/dimentorin/src/app/(public)/profile/_components/guards/mentor-guard.tsx create mode 100644 apps/dimentorin/src/app/(public)/profile/_components/index.ts create mode 100644 apps/dimentorin/src/app/(public)/profile/_components/modals/cv-modal.tsx create mode 100644 apps/dimentorin/src/app/(public)/profile/_components/modals/description-modal.tsx create mode 100644 apps/dimentorin/src/app/(public)/profile/_components/modals/edit-profile-modal.tsx create mode 100644 apps/dimentorin/src/app/(public)/profile/_components/modals/education-modal.tsx create mode 100644 apps/dimentorin/src/app/(public)/profile/_components/modals/experience-modal.tsx create mode 100644 apps/dimentorin/src/app/(public)/profile/_components/modals/index.ts create mode 100644 apps/dimentorin/src/app/(public)/profile/_components/modals/languages-modal.tsx create mode 100644 apps/dimentorin/src/app/(public)/profile/_components/modals/notification-modal.tsx create mode 100644 apps/dimentorin/src/app/(public)/profile/_components/modals/personal-info-modal.tsx create mode 100644 apps/dimentorin/src/app/(public)/profile/_components/modals/profile-basic-info-modal.tsx create mode 100644 apps/dimentorin/src/app/(public)/profile/_components/modals/skills-modal.tsx create mode 100644 apps/dimentorin/src/app/(public)/profile/_components/modals/social-media-modal.tsx create mode 100644 apps/dimentorin/src/app/(public)/profile/_components/profile/index.ts create mode 100644 apps/dimentorin/src/app/(public)/profile/_components/profile/profile-form-handlers.ts create mode 100644 apps/dimentorin/src/app/(public)/profile/_components/profile/profile-form-hooks.ts create mode 100644 apps/dimentorin/src/app/(public)/profile/_components/profile/profile-form-types.ts create mode 100644 apps/dimentorin/src/app/(public)/profile/_components/profile/profile-form.tsx create mode 100644 apps/dimentorin/src/app/(public)/profile/_components/profile/profile-header.tsx create mode 100644 apps/dimentorin/src/app/(public)/profile/_components/profile/profile-info-new.tsx create mode 100644 apps/dimentorin/src/app/(public)/profile/_components/profile/profile-info.tsx create mode 100644 apps/dimentorin/src/app/(public)/profile/_components/profile/profile-sidebar.tsx create mode 100644 apps/dimentorin/src/app/(public)/profile/_components/profile/profile-tabs.tsx create mode 100644 apps/dimentorin/src/app/(public)/profile/_components/sections/contact-info-section.tsx create mode 100644 apps/dimentorin/src/app/(public)/profile/_components/sections/cv-resume-section.tsx create mode 100644 apps/dimentorin/src/app/(public)/profile/_components/sections/description-section.tsx create mode 100644 apps/dimentorin/src/app/(public)/profile/_components/sections/education-section.tsx create mode 100644 apps/dimentorin/src/app/(public)/profile/_components/sections/experiences-section.tsx create mode 100644 apps/dimentorin/src/app/(public)/profile/_components/sections/index.ts create mode 100644 apps/dimentorin/src/app/(public)/profile/_components/sections/languages-section.tsx create mode 100644 apps/dimentorin/src/app/(public)/profile/_components/sections/personal-info-section.tsx create mode 100644 apps/dimentorin/src/app/(public)/profile/_components/sections/skills-section.tsx create mode 100644 apps/dimentorin/src/app/(public)/profile/_components/sections/social-media-section.tsx create mode 100644 apps/dimentorin/src/app/(public)/profile/_components/shared/file-uploader.tsx create mode 100644 apps/dimentorin/src/app/(public)/profile/_components/shared/index.ts create mode 100644 apps/dimentorin/src/app/(public)/profile/_components/shared/section-wrapper.tsx create mode 100644 apps/dimentorin/src/app/(public)/profile/page.tsx create mode 100644 apps/dimentorin/src/app/_hooks/use-google-login.ts create mode 100644 apps/dimentorin/src/app/auth/google-callback/page.tsx create mode 100644 apps/dimentorin/src/app/auth/google-oauth-popup/page.tsx create mode 100644 libs/service/src/api/mentors/index.ts create mode 100644 libs/service/src/api/upload/index.ts create mode 100644 libs/service/src/hooks/mentors/index.ts create mode 100644 libs/service/src/hooks/upload/index.ts create mode 100644 libs/service/src/types/mentors/index.ts diff --git a/apps/dimentorin/public/image/success.png b/apps/dimentorin/public/image/success.png new file mode 100644 index 0000000000000000000000000000000000000000..a34e8cccb03f0a49d0524b6594fe5ba13881d861 GIT binary patch literal 2875 zcmV-B3&iw^P)@5CC z%X){Mot^)lIp^GSKZcRdI6od~3IwQWhEbEn*fO!HiE#>Yq97$7c4ACC#Q#O{H|M;M zS=5`jPtjdVT6@VyFsd*YCL_(3Wo;KCHH()`lq;qVJqZ%xb2_)PzO{!cNJWH(xSY{G zi`esIR@h=h#o6}o1V3E~(LED7!Xv?@Q`XDEa*vRA;ua#B;QYjlu=pR;w?@eWTo>AB z0_Vc0?S6uva9eQKg!a9_S#V+}r+MO(TY|eH^o6^TBg|sQs0swmLb{Y)^{s7W7^6b} zbutoKz}Oq&&t|G_dhT<6*rat^WC&QX5cxx@evv^l&IRX0hS0WX>5K1M$3)s>>`VCx z`0kx`KYWEW(GnW2qQ+to(I9A|2b-vHC1D*`qAYaEA9ssCLo`3o5?jefqaHEOLI22zf(@`oX7H z=nZy`(Dze(3&NgLJTQ53O3Vf(#K%X%#N?hJqG-ell{%O75&pzX)(=g~*k% z;zG0tSF%G`w<(Ibki0^hOy#qD)bfUWSxDX{_(IDI{oEVa*79Z}xXkwE}+)zpY7*RcBaqK(iYPDcoBF?@~1bt2U5==2&%x z&?22XMj9YbzJ2cts&?89q@s+mm+h}-g~l)~(#SL}-Mmuq#)v6!Wuo<}Nie0UWL9WS zbchte7wf9B;8zNyefdhWPGF4orT?C7f(y5?ofP@t)BpJ1W&C`@K?*Y&T9tL1)#bc;F}2ur!-bTDit1<;@8S8HBWQ%}b?AECuHi)Od=-24}v ziM~xvKz}7#jT*?-k9B@3qTCG9`~t3W=<)sZwYrC9U&AlAUHucOfYz=CK{-Uj;_A6G zf`uQE!Ee=E@Vh%bNyk4tNfXm|Nd@%EYc*g3)MW95+PFDfQX}JII{4}M#iMY$;<&DSQN4`(bKmKFV0m1L+Q$NethTzxnzi#py#wH7u0v7h_N3UmWgV=T} zcM3$d`{n5Pu(9B08qb6VL!va99iVX8hnB1*r=U^NVB0TQk$au|3WP2>a)V{qEKH5`171biLhobSgkYil*6K# znvY(+cqQqA0(^FGd}+f^rR8RW;P=dmx&8@=j7Eu9Xt@qXy%yu7H0<8g*kS}n6y(z^ zP8IC!!BMw6$^@$xtDi4R2%%>JYC_l7)Eb)s)poUAT4)-$hr_AXo{8=#UF5k2ou{B_ z5}cyeu7z@9qLTY)2$~UkB2}BP2S+qy5FAZ(N9oe|&^poqlRwi97A333P*+pH4Zt1P zL2z`lB3CXOR;UXtGLCofY}sEp1p&h31j>tJ|E9AAtW~!=N)?>rMh%N*ENR4Pi|!#s zwivW(aHfF&eM9*1O0@iR4T*C)hi55tY8^NwKfU$g4W_4($>y{@I3oDjGZQbt+(cEF zsB@*)*s~Rt_-Rt4Z27&#Br*+0eC$X|JxeTE(i%7?+(Nx92 zZPQtZmmFB%qP+rw$er85SrhPcy0Ubw8kgPv^+qN}dd?1!JvC~y0@GNDOt&GoWpB{6 zYOqm!*=iFTBDaL}vWz7BR5;Ia5_vmkb*&npA+vX#vB~VNVoGFW8Uapui{yEQhE+4l zHGiida{r`pkFR~UIZGy)X@m5NCjhh)xe)&*0VF{8^1!67i3)ZY~Xn~xX# zR7+j!ky7s}OXzMM3C>KhZgOGyVhD~&M?)^mhe3xbx}n?=IGJVasM}Z8`0}z>a_pXr zgc6K(bC*_gM8Ld!Yf#RB(h)r^E^05Pd~iM>vc-8gYvd7RNRFf<$>rpAPzh)EE{9zG(=8*gaO>hv;6}<6{w}-<*X51 z3?o8=uF1$D&SVSnfe4ZadZKgnM5z{^gU>Su{PxTPa0@3Ch)}x-8@z>+QK8eoxXoP;f27qV|jzj zyWzWi;8MI4*mg|s!4hRG=~lLTC3^C<*8DWD&>fX4BD5VTI-`9;fF`D~$YC}qtHq47 z9z5pd?W&6*i&hILB$psU{hLuxe94gb5ti|+9GxX^x_$>U%Mu(jpD2dOPct*r>$e-Q Ze+j?4*9Yd^0BHaK002ovPDHLkV1jV)hExCm literal 0 HcmV?d00001 diff --git a/apps/dimentorin/src/app/(public)/profile/[id]/page.tsx b/apps/dimentorin/src/app/(public)/profile/[id]/page.tsx new file mode 100644 index 0000000..75d82c8 --- /dev/null +++ b/apps/dimentorin/src/app/(public)/profile/[id]/page.tsx @@ -0,0 +1,166 @@ +'use client'; + +import { FC, ReactElement, useState } from 'react'; +import { useParams } from 'react-router-dom'; +import { ProfileForm, ProfileSidebar, ProfileHeader } from '../_components'; +import { ArrowLeftOutlined } from '@ant-design/icons'; +import { Button } from '@imphnen-frontend-service/ui/atoms'; +import { NotificationModal, NotificationType } from '../_components/modals/notification-modal'; +import { ProfileProvider, useProfile } from '../_components/contexts/profile-context'; +import { EditProfileModal } from '../_components/modals/edit-profile-modal'; + +const ProfileByIdPage: FC = (): ReactElement => { + const params = useParams(); + const id = (params && params.id) ? params.id as string : undefined; + + if (!id) { + return ( +
+
+

Profile ID not found.

+
+
+ ); + } + + return ( + + + + ); +}; + +const ProfileByIdContent: FC = (): ReactElement => { + const { profileData, isLoading, error, profileType } = useProfile(); + + const [notification, setNotification] = useState<{ + isOpen: boolean; + type: 'success' | 'error'; + title: string; + message?: string; + }>({ + isOpen: false, + type: 'success', + title: '', + message: '' + }); + + const [isEditProfileModalOpen, setIsEditProfileModalOpen] = useState(false); + + const getProfileTitle = () => { + if (profileData?.fullname) { + return `${profileData.fullname}'s Profile`; + } + return profileType === 'user' ? 'User Profile' : 'Mentor Profile'; + }; + + const showNotification = (type: NotificationType['type'], title: string, message?: string) => { + setNotification({ + isOpen: true, + type, + title, + message + }); + }; + + const hideNotification = () => { + setNotification(prev => ({ ...prev, isOpen: false })); + }; + + const openEditProfileModal = () => { + setIsEditProfileModalOpen(true); + }; + + const closeEditProfileModal = () => { + setIsEditProfileModalOpen(false); + }; + + // Set isViewOnly to true for this page + const isViewOnly = true; + + if (isLoading) { + return ( +
+
+
+

Loading profile...

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

Failed to load profile

+

Profile not found or you don't have permission to view it.

+
+
+ ); + } + + return ( +
+
+
+
+ +
+
+
+ +
+
+

+ {getProfileTitle()} +

+
+
+ +
+
+
+
+ +
+
+ +
+ +
+ +
+
+
+
+ + {/* Only render EditProfileModal if not in view-only mode */} + {!isViewOnly && ( + + )} +
+ ); +}; + +export default ProfileByIdPage; \ No newline at end of file diff --git a/apps/dimentorin/src/app/(public)/profile/_components/buttons/edit-section-button.tsx b/apps/dimentorin/src/app/(public)/profile/_components/buttons/edit-section-button.tsx new file mode 100644 index 0000000..fdcddb5 --- /dev/null +++ b/apps/dimentorin/src/app/(public)/profile/_components/buttons/edit-section-button.tsx @@ -0,0 +1,25 @@ +import { FC } from 'react'; +import { EditOutlined } from '@ant-design/icons'; + +interface EditSectionButtonProps { + onClick: () => void; + disabled?: boolean; +} + +export const EditSectionButton: FC = ({ onClick, disabled = false }) => { + return ( + + ); +}; diff --git a/apps/dimentorin/src/app/(public)/profile/_components/buttons/index.ts b/apps/dimentorin/src/app/(public)/profile/_components/buttons/index.ts new file mode 100644 index 0000000..f39c2aa --- /dev/null +++ b/apps/dimentorin/src/app/(public)/profile/_components/buttons/index.ts @@ -0,0 +1,2 @@ +export { EditSectionButton } from './edit-section-button'; +export { ModalButton } from './modal-button'; diff --git a/apps/dimentorin/src/app/(public)/profile/_components/buttons/modal-button.tsx b/apps/dimentorin/src/app/(public)/profile/_components/buttons/modal-button.tsx new file mode 100644 index 0000000..01fae8c --- /dev/null +++ b/apps/dimentorin/src/app/(public)/profile/_components/buttons/modal-button.tsx @@ -0,0 +1,76 @@ +import { FC, ReactNode } from 'react'; + +type ButtonVariant = 'primary' | 'secondary' | 'danger'; +type ButtonSize = 'sm' | 'md' | 'lg'; + +interface ModalButtonProps { + children: ReactNode; + onClick?: () => void; + type?: 'button' | 'submit' | 'reset'; + variant?: ButtonVariant; + size?: ButtonSize; + disabled?: boolean; + loading?: boolean; + className?: string; +} + +const getVariantClasses = (variant: ButtonVariant): string => { + switch (variant) { + case 'primary': + return 'bg-[#23A1EB] hover:bg-[#1e90d6] text-white shadow-lg hover:shadow-xl'; + case 'secondary': + return 'bg-white hover:bg-gray-200 text-[#23A1EB] shadow-md hover:shadow-lg'; + case 'danger': + return 'bg-red-600 hover:bg-red-500 text-white shadow-lg hover:shadow-xl'; + default: + return 'bg-[#23A1EB] hover:bg-[#1e90d6] text-white shadow-lg hover:shadow-xl'; + } +}; + +const getSizeClasses = (size: ButtonSize): string => { + switch (size) { + case 'sm': + return 'px-3 py-1.5 text-sm'; + case 'md': + return 'px-4 py-2 text-sm'; + case 'lg': + return 'px-6 py-3 text-base'; + default: + return 'px-4 py-2 text-sm'; + } +}; + +export const ModalButton: FC = ({ + children, + onClick, + type = 'button', + variant = 'primary', + size = 'md', + disabled = false, + loading = false, + className = '', +}) => { + const baseClasses = 'font-medium rounded-lg transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-[#23A1EB] disabled:opacity-50 disabled:cursor-not-allowed'; + const variantClasses = getVariantClasses(variant); + const sizeClasses = getSizeClasses(size); + + const combinedClasses = `${baseClasses} ${variantClasses} ${sizeClasses} ${className}`; + + return ( + + ); +}; diff --git a/apps/dimentorin/src/app/(public)/profile/_components/contexts/index.ts b/apps/dimentorin/src/app/(public)/profile/_components/contexts/index.ts new file mode 100644 index 0000000..5450a18 --- /dev/null +++ b/apps/dimentorin/src/app/(public)/profile/_components/contexts/index.ts @@ -0,0 +1 @@ +export * from './profile-context'; diff --git a/apps/dimentorin/src/app/(public)/profile/_components/contexts/profile-context.tsx b/apps/dimentorin/src/app/(public)/profile/_components/contexts/profile-context.tsx new file mode 100644 index 0000000..486299f --- /dev/null +++ b/apps/dimentorin/src/app/(public)/profile/_components/contexts/profile-context.tsx @@ -0,0 +1,250 @@ +'use client'; + +import React, { createContext, useContext, useMemo, useCallback } from 'react'; +import { useParams } from 'react-router-dom'; +import { useQueryClient } from '@tanstack/react-query'; +import { useAuthStore } from '@imphnen-frontend-service/utils'; +import { + + useUserMe, + useUserById, + useUpdateUserMe, + useUpdateUserById, + UserDetailResponseDto, + UserUpdateRequestDto, + + useMentorMe, + useMentorById, + useUpdateMentorMe, + useUpdateMentorById, + MentorDetailResponseDto, + MentorUpdateRequestDto +} from '@imphnen-frontend-service/service'; + + +type ProfileData = UserDetailResponseDto | MentorDetailResponseDto; +type ProfileUpdateData = UserUpdateRequestDto | MentorUpdateRequestDto; + + +const canAccessMentorFeatures = (user: { role?: { name?: string; permissions?: Array<{ name?: string }> } } | null) => { + if (!user?.role) return false; + + const roleName = user.role.name?.toLowerCase() || ''; + const isMentorRole = roleName.includes('mentor') || roleName.includes('admin'); + + if (isMentorRole) return true; + + + const permissions = user.role.permissions || []; + const hasMentorPermission = permissions.some((permission: { name?: string }) => + permission.name?.toLowerCase().includes('mentor') + ); + + return hasMentorPermission; +}; + +interface ProfileContextType { + profileData: ProfileData | undefined; + isLoading: boolean; + error: unknown; + isOwnProfile: boolean; + profileId: string | null; + profileType: 'user' | 'mentor'; + updateProfile: (data: ProfileUpdateData) => Promise; + isUpdating: boolean; + canAccessMentor: boolean; +}const ProfileContext = createContext(undefined); + +interface ProfileProviderProps { + children: React.ReactNode; + profileId?: string; + profileType?: 'user' | 'mentor'; +} + +export const ProfileProvider: React.FC = ({ + children, + profileId, + profileType: forcedProfileType +}) => { + const params = useParams(); + const { session } = useAuthStore(); + const queryClient = useQueryClient(); + + + const canAccessMentor = useMemo(() => { + return canAccessMentorFeatures(session?.user || null); + }, [session?.user]); + + + const isMentorRole = useMemo(() => { + const roleName = session?.user?.role?.name?.toLowerCase() || ''; + return roleName === 'mentor'; + }, [session?.user?.role?.name]); + + + const profileType: 'user' | 'mentor' = useMemo(() => { + if (forcedProfileType) { + + if (forcedProfileType === 'mentor' && !isMentorRole) { + return 'user'; + } + return forcedProfileType; + } + + + if ((params?.mentor || (typeof window !== 'undefined' && window.location.pathname.includes('/mentor'))) && isMentorRole) { + return 'mentor'; + } + + return 'user'; + }, [forcedProfileType, params, isMentorRole]); + + + const id = profileId || (params?.id as string) || undefined; + const isOwnProfile = !id; + + + + + const userMeQuery = useUserMe({ + queryKey: ['user-me'], + enabled: isOwnProfile && profileType === 'user', + }); + const userByIdQuery = useUserById(id || '', { + queryKey: ['user-by-id', id], + enabled: !isOwnProfile && !!id && profileType === 'user', + }); + const updateUserMeMutation = useUpdateUserMe(); + const updateUserByIdMutation = useUpdateUserById(); + + const mentorMeQuery = useMentorMe({ + queryKey: ['mentor-me'], + enabled: isOwnProfile && profileType === 'mentor' && canAccessMentor, + }); + const mentorByIdQuery = useMentorById(id || '', { + queryKey: ['mentor-by-id', id], + enabled: !isOwnProfile && !!id && profileType === 'mentor' && canAccessMentor, + }); + const updateMentorMeMutation = useUpdateMentorMe(); + const updateMentorByIdMutation = useUpdateMentorById(); + + + const selectedUserQuery = isOwnProfile ? userMeQuery : userByIdQuery; + const selectedMentorQuery = isOwnProfile ? mentorMeQuery : mentorByIdQuery; + + const { + data: profileData, + isLoading, + error + } = useMemo(() => { + + if (canAccessMentor && profileType === 'mentor') { + return selectedMentorQuery; + } + + return selectedUserQuery; + }, [profileType, canAccessMentor, selectedUserQuery, selectedMentorQuery]); + + + const selectedUserMutation = isOwnProfile ? updateUserMeMutation : updateUserByIdMutation; + const selectedMentorMutation = isOwnProfile ? updateMentorMeMutation : updateMentorByIdMutation; + + const updateMutation = useMemo(() => { + + if (canAccessMentor && profileType === 'mentor') { + return selectedMentorMutation; + } + + return selectedUserMutation; + }, [profileType, canAccessMentor, selectedUserMutation, selectedMentorMutation]); + + + const updateProfile = useCallback(async (data: ProfileUpdateData) => { + try { + if (canAccessMentor && profileType === 'mentor') { + + if (isOwnProfile) { + await updateMentorMeMutation.mutateAsync(data as MentorUpdateRequestDto); + + await queryClient.invalidateQueries({ queryKey: ['mentor-me'] }); + } else if (id) { + await updateMentorByIdMutation.mutateAsync({ id, data: data as MentorUpdateRequestDto }); + + await queryClient.invalidateQueries({ queryKey: ['mentor-by-id', id] }); + } + } else if (isOwnProfile) { + + await updateUserMeMutation.mutateAsync(data as UserUpdateRequestDto); + + await queryClient.invalidateQueries({ queryKey: ['user-me'] }); + } else if (id) { + await updateUserByIdMutation.mutateAsync({ id, data: data as UserUpdateRequestDto }); + + await queryClient.invalidateQueries({ queryKey: ['user-by-id', id] }); + } + } catch (error: unknown) { + console.error('Failed to update profile:', error); + + let apiMessage = ''; + if (typeof error === 'object' && error !== null) { + const errObj = error as { response?: { data?: unknown } }; + const data = errObj.response?.data; + if (data) { + try { + const parsed = typeof data === 'string' ? JSON.parse(data) : data; + if (parsed && typeof parsed.message === 'string') { + apiMessage = parsed.message; + } + } catch { + apiMessage = typeof data === 'string' ? data : ''; + } + } + } + if (apiMessage) { + throw new Error(apiMessage); + } + throw error; + } + }, [ + profileType, + isOwnProfile, + canAccessMentor, + id, + queryClient, + updateUserMeMutation, + updateUserByIdMutation, + updateMentorMeMutation, + updateMentorByIdMutation + ]); const isUpdating = updateMutation.isPending; + + const value: ProfileContextType = useMemo(() => ({ + profileData, + isLoading, + error, + isOwnProfile, + profileId: isOwnProfile ? null : (id || null), + profileType, + updateProfile, + isUpdating, + canAccessMentor + }), [profileData, isLoading, error, isOwnProfile, id, profileType, updateProfile, isUpdating, canAccessMentor]); + + return ( + + {children} + + ); +}; + + +export const useProfile = (): ProfileContextType => { + const context = useContext(ProfileContext); + if (!context) { + throw new Error('useProfile must be used within a ProfileProvider'); + } + return context; +}; + + +export type { ProfileContextType }; +export type { ProfileData, ProfileUpdateData }; diff --git a/apps/dimentorin/src/app/(public)/profile/_components/guards/mentor-guard.tsx b/apps/dimentorin/src/app/(public)/profile/_components/guards/mentor-guard.tsx new file mode 100644 index 0000000..48c5735 --- /dev/null +++ b/apps/dimentorin/src/app/(public)/profile/_components/guards/mentor-guard.tsx @@ -0,0 +1,27 @@ +'use client'; + +import React from 'react'; +import { Guard } from '@imphnen-frontend-service/utils'; + +interface MentorGuardProps { + children: React.ReactNode; + fallback?: React.ReactNode; +} + +export const MentorGuard: React.FC = ({ + children, + fallback = ( +
+

Akses ditolak: Anda tidak memiliki izin untuk mengakses fitur mentor.

+
+ ) +}) => { + return ( + + {children} + + ); +}; diff --git a/apps/dimentorin/src/app/(public)/profile/_components/index.ts b/apps/dimentorin/src/app/(public)/profile/_components/index.ts new file mode 100644 index 0000000..ed05848 --- /dev/null +++ b/apps/dimentorin/src/app/(public)/profile/_components/index.ts @@ -0,0 +1,17 @@ + +export * from './profile'; + + +export * from './sections'; + + +export * from './buttons'; + + +export * from './shared'; + + +export * from './modals'; + + +export * from './contexts'; diff --git a/apps/dimentorin/src/app/(public)/profile/_components/modals/cv-modal.tsx b/apps/dimentorin/src/app/(public)/profile/_components/modals/cv-modal.tsx new file mode 100644 index 0000000..c37ff24 --- /dev/null +++ b/apps/dimentorin/src/app/(public)/profile/_components/modals/cv-modal.tsx @@ -0,0 +1,187 @@ +import { FC, useState, useEffect } from 'react'; +import { ModalButton } from '../buttons/modal-button'; +import { useUploadCV } from '@imphnen-frontend-service/service'; +import { FileUploader } from '../shared/file-uploader'; + +interface CVData { + fileName: string; + fileUrl?: string; +} + +interface CVModalProps { + isOpen: boolean; + onClose: () => void; + initialValue: CVData; + onSave: (value: CVData) => Promise; + isLoading?: boolean; +} + +export const CVModal: FC = ({ + isOpen, + onClose, + initialValue, + onSave, + isLoading = false, +}) => { + const [cvData, setCvData] = useState(initialValue); + const [isUploading, setIsUploading] = useState(false); + const uploadCVMutation = useUploadCV(); + + + useEffect(() => { + setCvData(initialValue); + }, [initialValue]); + + const handleSave = async () => { + try { + await onSave(cvData); + + onClose(); + } catch (error) { + console.error('Save failed:', error); + + } + }; + + const handleCancel = () => { + setCvData(initialValue); + onClose(); + }; + + const handleFileSelect = async (file: File) => { + try { + setIsUploading(true); + + + if (!file.type.includes('pdf')) { + throw new Error('Please select a PDF file'); + } + + + const uploadResult = await uploadCVMutation.mutateAsync(file); + + console.log('CV upload response:', uploadResult); + + + interface UploadData { + original_filename?: string; + filename?: string; + url?: string; + } + + const uploadData = ('data' in uploadResult ? (uploadResult as { data: UploadData }).data : uploadResult as UploadData); + + setCvData({ + fileName: uploadData.original_filename || uploadData.filename || file.name, + fileUrl: uploadData.url || '', + }); + + console.log('CV uploaded successfully, URL:', uploadData.url); + } catch (error) { + console.error('CV upload error:', error); + + const fileInput = document.getElementById('cv-upload') as HTMLInputElement; + if (fileInput) fileInput.value = ''; + } finally { + setIsUploading(false); + } + }; + + if (!isOpen) return null; + + return ( +
+ +
+ ); +}; + + diff --git a/apps/dimentorin/src/app/(public)/profile/_components/modals/description-modal.tsx b/apps/dimentorin/src/app/(public)/profile/_components/modals/description-modal.tsx new file mode 100644 index 0000000..9bc2269 --- /dev/null +++ b/apps/dimentorin/src/app/(public)/profile/_components/modals/description-modal.tsx @@ -0,0 +1,99 @@ +import { FC, useState, useEffect } from 'react'; +import { ModalButton } from '../buttons/modal-button'; + +interface DescriptionModalProps { + isOpen: boolean; + onClose: () => void; + initialValue: string; + onSave: (value: string) => Promise; + isLoading?: boolean; +} + +export const DescriptionModal: FC = ({ + isOpen, + onClose, + initialValue, + onSave, + isLoading = false, +}) => { + const [description, setDescription] = useState(initialValue); + + + useEffect(() => { + setDescription(initialValue); + }, [initialValue]); + + const handleSave = async () => { + try { + await onSave(description); + + onClose(); + } catch (error) { + console.error('Save failed:', error); + + } + }; + + const handleCancel = () => { + setDescription(initialValue); + onClose(); + }; + + if (!isOpen) return null; + + return ( +
+