diff --git a/apps/dimentorin/public/image/success.png b/apps/dimentorin/public/image/success.png new file mode 100644 index 0000000..a34e8cc Binary files /dev/null and b/apps/dimentorin/public/image/success.png differ 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 ( +
+