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.
This commit is contained in:
Binary file not shown.
|
After Width: | Height: | Size: 2.8 KiB |
@@ -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 (
|
||||
<main className="min-h-screen flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<p className="text-red-600 text-lg">Profile ID not found.</p>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ProfileProvider profileId={id} profileType="user">
|
||||
<ProfileByIdContent />
|
||||
</ProfileProvider>
|
||||
);
|
||||
};
|
||||
|
||||
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 (
|
||||
<main className="min-h-screen flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto"></div>
|
||||
<p className="mt-4 text-gray-600">Loading profile...</p>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<main className="min-h-screen flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<p className="text-red-600 text-lg">Failed to load profile</p>
|
||||
<p className="text-gray-600 mt-2">Profile not found or you don't have permission to view it.</p>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="min-h-screen">
|
||||
<div className="">
|
||||
<div className="w-full px-8 md:px-[60px] lg:px-20 py-4">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<Button variant="primary" className="flex items-center gap-2">
|
||||
<ArrowLeftOutlined />
|
||||
Kembali ke Dashboard
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="w-full px-8 md:px-[60px] lg:px-20 py-6">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<h1 className="text-2xl font-semibold text-gray-900">
|
||||
{getProfileTitle()}
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="w-full px-8 md:px-[60px] lg:px-20 pb-12">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<div className="grid gap-8 lg:grid-cols-12">
|
||||
<div className="lg:col-span-12">
|
||||
<ProfileHeader onEditProfileClick={openEditProfileModal} isViewOnly={isViewOnly} />
|
||||
</div>
|
||||
<div className="lg:col-span-8 order-1">
|
||||
<ProfileForm
|
||||
showNotification={showNotification}
|
||||
isViewOnly={isViewOnly}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="lg:col-span-4 order-2">
|
||||
<ProfileSidebar
|
||||
showNotification={showNotification}
|
||||
isViewOnly={isViewOnly}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<NotificationModal
|
||||
isOpen={notification.isOpen}
|
||||
onClose={hideNotification}
|
||||
type={notification.type}
|
||||
title={notification.title}
|
||||
message={notification.message}
|
||||
header="Profile"
|
||||
/>
|
||||
{/* Only render EditProfileModal if not in view-only mode */}
|
||||
{!isViewOnly && (
|
||||
<EditProfileModal
|
||||
isOpen={isEditProfileModalOpen}
|
||||
onClose={closeEditProfileModal}
|
||||
showNotification={showNotification}
|
||||
/>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProfileByIdPage;
|
||||
@@ -0,0 +1,25 @@
|
||||
import { FC } from 'react';
|
||||
import { EditOutlined } from '@ant-design/icons';
|
||||
|
||||
interface EditSectionButtonProps {
|
||||
onClick: () => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export const EditSectionButton: FC<EditSectionButtonProps> = ({ onClick, disabled = false }) => {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
className={`flex items-center gap-1 px-3 py-1 text-sm transition-colors duration-200 rounded-md ${
|
||||
disabled
|
||||
? 'text-gray-400 cursor-not-allowed opacity-50'
|
||||
: 'text-[#23A1EB] hover:text-[#1e90d6] hover:bg-[#23A1EB]/10'
|
||||
}`}
|
||||
aria-label="Edit section"
|
||||
>
|
||||
<span>Edit</span>
|
||||
<EditOutlined className="w-3 h-3" />
|
||||
</button>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,2 @@
|
||||
export { EditSectionButton } from './edit-section-button';
|
||||
export { ModalButton } from './modal-button';
|
||||
@@ -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<ModalButtonProps> = ({
|
||||
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 (
|
||||
<button
|
||||
type={type}
|
||||
onClick={onClick}
|
||||
disabled={disabled || loading}
|
||||
className={combinedClasses}
|
||||
>
|
||||
{loading ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-4 h-4 border-2 border-current border-t-transparent rounded-full animate-spin"></div>
|
||||
<span>Loading...</span>
|
||||
</div>
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export * from './profile-context';
|
||||
@@ -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<void>;
|
||||
isUpdating: boolean;
|
||||
canAccessMentor: boolean;
|
||||
}const ProfileContext = createContext<ProfileContextType | undefined>(undefined);
|
||||
|
||||
interface ProfileProviderProps {
|
||||
children: React.ReactNode;
|
||||
profileId?: string;
|
||||
profileType?: 'user' | 'mentor';
|
||||
}
|
||||
|
||||
export const ProfileProvider: React.FC<ProfileProviderProps> = ({
|
||||
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 (
|
||||
<ProfileContext.Provider value={value}>
|
||||
{children}
|
||||
</ProfileContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
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 };
|
||||
@@ -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<MentorGuardProps> = ({
|
||||
children,
|
||||
fallback = (
|
||||
<div className="p-4 text-center text-red-500">
|
||||
<p>Akses ditolak: Anda tidak memiliki izin untuk mengakses fitur mentor.</p>
|
||||
</div>
|
||||
)
|
||||
}) => {
|
||||
return (
|
||||
<Guard
|
||||
permissions={['mentor', 'admin']}
|
||||
fallback={fallback}
|
||||
>
|
||||
{children}
|
||||
</Guard>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,17 @@
|
||||
|
||||
export * from './profile';
|
||||
|
||||
|
||||
export * from './sections';
|
||||
|
||||
|
||||
export * from './buttons';
|
||||
|
||||
|
||||
export * from './shared';
|
||||
|
||||
|
||||
export * from './modals';
|
||||
|
||||
|
||||
export * from './contexts';
|
||||
@@ -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<void>;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
export const CVModal: FC<CVModalProps> = ({
|
||||
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 (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
|
||||
<button
|
||||
className="absolute inset-0 bg-black/20 backdrop-blur-sm"
|
||||
onClick={handleCancel}
|
||||
aria-label="Close modal"
|
||||
type="button"
|
||||
/>
|
||||
|
||||
|
||||
<div className="relative bg-white rounded-xl shadow-xl w-full max-w-5xl mx-auto">
|
||||
|
||||
<div className="p-6 pb-4 border-b border-gray-200">
|
||||
<div className="flex">
|
||||
<h2 className="text-xl font-semibold text-gray-900 px-3 py-1 bg-[#23A1EB]/10 rounded-md flex-1">Edit CV/Resume</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="px-6 py-6 space-y-6">
|
||||
{}
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-gray-700 mb-2">
|
||||
Upload CV/Resume
|
||||
</h3>
|
||||
<FileUploader
|
||||
accept=".pdf"
|
||||
maxSize={10 * 1024 * 1024}
|
||||
onFileSelect={handleFileSelect}
|
||||
isLoading={isUploading}
|
||||
dragAndDrop={true}
|
||||
description="Klik atau tarik file PDF yang ingin di upload"
|
||||
className="w-full"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-2">
|
||||
Format yang didukung: PDF • Maksimal ukuran: 10MB
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{}
|
||||
{cvData.fileName && (
|
||||
<div className="p-4 bg-blue-50 border border-blue-200 rounded-lg">
|
||||
<h4 className="text-sm font-medium text-blue-900 mb-3">File Terpilih</h4>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-12 h-12 bg-blue-500 rounded-lg flex items-center justify-center">
|
||||
<span className="text-white text-xs font-bold">PDF</span>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="font-medium text-blue-900 text-sm">{cvData.fileName}</p>
|
||||
<p className="text-xs text-blue-600">Siap untuk disimpan</p>
|
||||
</div>
|
||||
{cvData.fileUrl && (
|
||||
<a
|
||||
href={cvData.fileUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 hover:text-blue-800 text-sm font-medium underline"
|
||||
>
|
||||
Preview
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
{}
|
||||
<div className="flex gap-3 p-6 pt-4 border-t border-gray-100">
|
||||
<ModalButton
|
||||
variant="secondary"
|
||||
onClick={handleCancel}
|
||||
className="flex-1"
|
||||
disabled={isLoading || isUploading}
|
||||
>
|
||||
Batal
|
||||
</ModalButton>
|
||||
<ModalButton
|
||||
variant="primary"
|
||||
onClick={handleSave}
|
||||
className="flex-1"
|
||||
disabled={isLoading || isUploading || !cvData.fileName}
|
||||
loading={isLoading}
|
||||
>
|
||||
{isLoading ? 'Menyimpan...' : 'Simpan CV'}
|
||||
</ModalButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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<void>;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
export const DescriptionModal: FC<DescriptionModalProps> = ({
|
||||
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 (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<button
|
||||
className="absolute inset-0 bg-black/20 backdrop-blur-sm"
|
||||
onClick={handleCancel}
|
||||
aria-label="Close modal"
|
||||
type="button"
|
||||
/>
|
||||
|
||||
<div className="relative bg-white rounded-xl shadow-xl w-full max-w-5xl mx-auto">
|
||||
<div className="p-6 pb-4 border-b border-gray-200">
|
||||
<div className="flex">
|
||||
<h2 className="text-xl font-semibold text-gray-900 px-3 py-1 bg-[#23A1EB]/10 rounded-md flex-1">Edit Description</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="px-6 py-4">
|
||||
<div>
|
||||
<label htmlFor="description-textarea" className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Description
|
||||
</label>
|
||||
<textarea
|
||||
id="description-textarea"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
rows={8}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#23A1EB] focus:border-[#23A1EB] outline-none transition-colors resize-none"
|
||||
placeholder="Write your description here..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 p-6 pt-4">
|
||||
<ModalButton
|
||||
variant="secondary"
|
||||
onClick={handleCancel}
|
||||
className="flex-1"
|
||||
disabled={isLoading}
|
||||
>
|
||||
Batal
|
||||
</ModalButton>
|
||||
<ModalButton
|
||||
variant="primary"
|
||||
onClick={handleSave}
|
||||
className="flex-1"
|
||||
disabled={isLoading}
|
||||
loading={isLoading}
|
||||
>
|
||||
{isLoading ? 'Menyimpan...' : 'Simpan'}
|
||||
</ModalButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
import React, { FC, useState, useEffect } from 'react';
|
||||
import { Modal, InputField } from '@imphnen-frontend-service/ui/molecules';
|
||||
import { Button } from '@imphnen-frontend-service/ui/atoms';
|
||||
import { useProfile } from '../contexts/profile-context';
|
||||
import { CameraOutlined } from '@ant-design/icons';
|
||||
import { useUploadAvatar } from '@imphnen-frontend-service/service';
|
||||
|
||||
interface EditProfileModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
showNotification: (type: 'success' | 'error', title: string, message?: string) => void;
|
||||
}
|
||||
|
||||
export const EditProfileModal: FC<EditProfileModalProps> = ({ isOpen, onClose, showNotification }) => {
|
||||
const { profileData, profileType, updateProfile } = useProfile();
|
||||
const uploadAvatarMutation = useUploadAvatar();
|
||||
const [formData, setFormData] = useState({
|
||||
fullname: '',
|
||||
avatar: ''
|
||||
});
|
||||
const [previewUrl, setPreviewUrl] = useState<string>('/image/testimonial.webp');
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (profileData) {
|
||||
const fullname = profileData.fullname ||
|
||||
(profileType === 'mentor' && 'legal_name' in profileData ? profileData.legal_name : '') || '';
|
||||
|
||||
|
||||
const avatar = (profileType === 'user' && 'avatar' in profileData)
|
||||
? profileData.avatar || '/image/testimonial.webp'
|
||||
: '/image/testimonial.webp';
|
||||
|
||||
console.log('Modal - Profile data avatar URL:', avatar);
|
||||
console.log('Modal - Profile data:', profileData);
|
||||
|
||||
setFormData({
|
||||
fullname,
|
||||
avatar: (profileType === 'user' && 'avatar' in profileData) ? profileData.avatar || '' : ''
|
||||
});
|
||||
|
||||
|
||||
setPreviewUrl(avatar);
|
||||
} else {
|
||||
|
||||
setPreviewUrl('/image/testimonial.webp');
|
||||
}
|
||||
}, [profileData, profileType]);
|
||||
|
||||
const handleImageError = () => {
|
||||
console.log('Image failed to load:', previewUrl);
|
||||
setPreviewUrl('/image/testimonial.webp');
|
||||
};
|
||||
|
||||
const handleNameChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setFormData(prev => ({ ...prev, fullname: e.target.value }));
|
||||
};
|
||||
|
||||
const handleImageUpload = async (file: File) => {
|
||||
try {
|
||||
setIsUploading(true);
|
||||
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
const result = reader.result as string;
|
||||
setPreviewUrl(result);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
|
||||
|
||||
const uploadResult = await uploadAvatarMutation.mutateAsync(file);
|
||||
|
||||
console.log('Avatar upload response:', uploadResult);
|
||||
|
||||
|
||||
interface UploadData {
|
||||
url?: string;
|
||||
}
|
||||
|
||||
const uploadData = ('data' in uploadResult ? (uploadResult as { data: UploadData }).data : uploadResult as UploadData);
|
||||
|
||||
|
||||
setFormData(prev => ({ ...prev, avatar: uploadData.url || '' }));
|
||||
|
||||
|
||||
setPreviewUrl(uploadData.url || '/image/testimonial.webp');
|
||||
|
||||
} catch (error) {
|
||||
console.error('Avatar upload error:', error);
|
||||
showNotification('error', 'Upload Failed', 'Failed to upload avatar image');
|
||||
|
||||
const originalAvatar = (profileType === 'user' && profileData && 'avatar' in profileData) ? profileData.avatar : '';
|
||||
setPreviewUrl(originalAvatar || '/image/testimonial.webp');
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
const updates: Record<string, string> = {};
|
||||
|
||||
|
||||
if (formData.fullname.trim() !== '') {
|
||||
if (profileType === 'user') {
|
||||
updates.fullname = formData.fullname;
|
||||
} else if (profileType === 'mentor') {
|
||||
updates.legal_name = formData.fullname;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (formData.avatar && formData.avatar !== (profileData && 'avatar' in profileData ? profileData.avatar : '')) {
|
||||
updates.avatar = formData.avatar;
|
||||
}
|
||||
|
||||
if (Object.keys(updates).length > 0) {
|
||||
await updateProfile(updates);
|
||||
showNotification('success', 'Profile Updated', 'Your profile has been successfully updated.');
|
||||
}
|
||||
|
||||
onClose();
|
||||
} catch (err: unknown) {
|
||||
console.error('Profile update error:', err);
|
||||
let apiMessage = '';
|
||||
if (typeof err === 'object' && err !== null) {
|
||||
const errObj = err as { response?: { data?: { message?: string } } };
|
||||
let backendMsg = '';
|
||||
if (errObj.response?.data?.message) {
|
||||
backendMsg = errObj.response.data.message;
|
||||
}
|
||||
let msg = '';
|
||||
if ('message' in err && typeof (err as { message?: string }).message === 'string') {
|
||||
msg = (err as { message?: string }).message || '';
|
||||
|
||||
if (msg.trim().startsWith('{') && msg.trim().endsWith('}')) {
|
||||
try {
|
||||
const parsed = JSON.parse(msg);
|
||||
if (parsed && typeof parsed.message === 'string') {
|
||||
msg = parsed.message;
|
||||
}
|
||||
} catch {
|
||||
// Ignore JSON parse errors
|
||||
}
|
||||
}
|
||||
}
|
||||
if (backendMsg && msg && backendMsg !== msg) {
|
||||
apiMessage = backendMsg + '\n' + msg;
|
||||
} else if (backendMsg) {
|
||||
apiMessage = backendMsg;
|
||||
} else if (msg) {
|
||||
apiMessage = msg;
|
||||
}
|
||||
}
|
||||
showNotification('error', 'Failed to save changes', apiMessage || 'Please try again.');
|
||||
}
|
||||
}; return (
|
||||
<Modal isOpen={isOpen} onClose={onClose}>
|
||||
<Modal.Header>
|
||||
<Modal.Title>Edit Profile</Modal.Title>
|
||||
</Modal.Header>
|
||||
<Modal.Content>
|
||||
{}
|
||||
<div className="flex justify-center pt-6 pb-4">
|
||||
<div className="relative">
|
||||
{}
|
||||
<input
|
||||
id="avatar-upload"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) handleImageUpload(file);
|
||||
}}
|
||||
className="hidden"
|
||||
disabled={isUploading}
|
||||
/>
|
||||
|
||||
<label htmlFor="avatar-upload" className="cursor-pointer block relative group">
|
||||
<img
|
||||
src={previewUrl}
|
||||
alt="Profile"
|
||||
className="w-24 h-24 rounded-full object-cover border-4 border-blue-100 shadow-lg transition-all duration-300 group-hover:border-blue-200"
|
||||
onError={handleImageError}
|
||||
/>
|
||||
|
||||
{/* Hover overlay */}
|
||||
<div className="absolute inset-0 rounded-full bg-opacity-0 group-hover:bg-opacity-20 transition-all duration-300 flex items-center justify-center">
|
||||
<span className="text-white text-xs opacity-0 group-hover:opacity-100 transition-opacity duration-300">
|
||||
Change Photo
|
||||
</span>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
{}
|
||||
<label htmlFor="avatar-upload" className="cursor-pointer">
|
||||
<div className="absolute bottom-0 right-0 w-8 h-8 bg-blue-500 hover:bg-blue-600 text-white rounded-full flex items-center justify-center transition-all duration-300 shadow-lg border-2 border-white">
|
||||
{isUploading ? (
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
|
||||
) : (
|
||||
<CameraOutlined className="text-xs" />
|
||||
)}
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div> <div className="px-6 pb-6 space-y-6">
|
||||
{}
|
||||
<div className="space-y-2">
|
||||
<InputField
|
||||
label="Full Name"
|
||||
name="fullname"
|
||||
value={formData.fullname}
|
||||
onChange={handleNameChange}
|
||||
placeholder="Enter your full name"
|
||||
className="w-full"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 pl-1">
|
||||
This name will be displayed on your profile
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Modal.Content>
|
||||
<Modal.Footer>
|
||||
<div className="flex gap-3 w-full">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={onClose}
|
||||
className="flex-1"
|
||||
disabled={isUploading}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={handleSave}
|
||||
className="flex-1"
|
||||
disabled={isUploading}
|
||||
>
|
||||
{isUploading ? (
|
||||
<>
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
|
||||
Saving...
|
||||
</>
|
||||
) : (
|
||||
'Save Changes'
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</Modal.Footer>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,184 @@
|
||||
import { FC, useState, useEffect } from 'react';
|
||||
import { PlusOutlined, DeleteOutlined } from '@ant-design/icons';
|
||||
import { ModalButton } from '../buttons/modal-button';
|
||||
import { InputField } from '@imphnen-frontend-service/ui/molecules';
|
||||
|
||||
interface Education {
|
||||
id: string;
|
||||
institution: string;
|
||||
degree: string;
|
||||
field: string;
|
||||
period: string;
|
||||
}
|
||||
|
||||
interface EducationModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
initialValue: Education[];
|
||||
onSave: (value: Education[]) => Promise<void>;
|
||||
isLoading?: boolean;
|
||||
showNotification?: (type: 'success' | 'error', title: string, message?: string) => void;
|
||||
}
|
||||
|
||||
export const EducationModal: FC<EducationModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
initialValue,
|
||||
onSave,
|
||||
isLoading = false,
|
||||
showNotification,
|
||||
}) => {
|
||||
const [educations, setEducations] = useState<Education[]>(initialValue);
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
setEducations(initialValue);
|
||||
}, [initialValue]);
|
||||
|
||||
const handleSave = async () => {
|
||||
|
||||
const hasEmpty = educations.some(edu =>
|
||||
!edu.institution.trim() || !edu.degree.trim() || !edu.field.trim() || !edu.period.trim()
|
||||
);
|
||||
if (hasEmpty) {
|
||||
if (showNotification) {
|
||||
showNotification('error', 'Data Tidak Lengkap', 'Semua field harus diisi pada setiap pendidikan.');
|
||||
} else {
|
||||
alert('Semua field harus diisi pada setiap pendidikan.');
|
||||
}
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await onSave(educations);
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error('Save failed:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
setEducations(initialValue);
|
||||
onClose();
|
||||
};
|
||||
|
||||
const addEducation = () => {
|
||||
const newEducation: Education = {
|
||||
id: Date.now().toString(),
|
||||
institution: '',
|
||||
degree: '',
|
||||
field: '',
|
||||
period: '',
|
||||
};
|
||||
setEducations([...educations, newEducation]);
|
||||
};
|
||||
|
||||
const removeEducation = (id: string) => {
|
||||
setEducations(educations.filter(edu => edu.id !== id));
|
||||
};
|
||||
|
||||
const updateEducation = (id: string, field: keyof Education, value: string) => {
|
||||
setEducations(educations.map(edu =>
|
||||
edu.id === id ? { ...edu, [field]: value } : edu
|
||||
));
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<button
|
||||
className="absolute inset-0 bg-black/20 backdrop-blur-sm"
|
||||
onClick={handleCancel}
|
||||
aria-label="Close modal"
|
||||
></button>
|
||||
|
||||
<div className="relative bg-white rounded-xl shadow-xl max-w-5xl w-full max-h-[95vh] overflow-hidden">
|
||||
|
||||
<div className="p-6 border-b border-gray-200">
|
||||
<div className="flex">
|
||||
<h2 className="text-xl font-semibold text-gray-900 px-3 py-1 bg-[#23A1EB]/10 rounded-md flex-1">Edit Education</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="p-6 max-h-[60vh] overflow-y-auto">
|
||||
<div className="space-y-6">
|
||||
{educations.map((education, index) => (
|
||||
<div key={education.id} className="border border-gray-200 rounded-lg p-4">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-medium text-gray-900">Education {index + 1}</h3>
|
||||
<ModalButton
|
||||
variant="danger"
|
||||
size="sm"
|
||||
onClick={() => removeEducation(education.id)}
|
||||
className="text-red-500 hover:text-red-700"
|
||||
>
|
||||
<DeleteOutlined />
|
||||
</ModalButton>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<InputField
|
||||
label="Institution"
|
||||
value={education.institution}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => updateEducation(education.id, 'institution', e.target.value)}
|
||||
placeholder="Enter institution name"
|
||||
/>
|
||||
<InputField
|
||||
label="Degree"
|
||||
value={education.degree}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => updateEducation(education.id, 'degree', e.target.value)}
|
||||
placeholder="Enter degree"
|
||||
/>
|
||||
<InputField
|
||||
label="Field"
|
||||
value={education.field}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => updateEducation(education.id, 'field', e.target.value)}
|
||||
placeholder="Enter field of study"
|
||||
/>
|
||||
<InputField
|
||||
label="Period"
|
||||
value={education.period}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => updateEducation(education.id, 'period', e.target.value)}
|
||||
placeholder="e.g., Sep 2022 - Current"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<ModalButton
|
||||
variant="secondary"
|
||||
onClick={addEducation}
|
||||
className="w-full border-2 border-dashed border-gray-300 rounded-lg p-4 text-gray-500 hover:border-[#23A1EB] hover:text-[#23A1EB] transition-colors flex items-center justify-center gap-2"
|
||||
>
|
||||
<PlusOutlined />
|
||||
Add Education
|
||||
</ModalButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="flex items-center justify-end gap-3 p-6 border-t border-gray-200 bg-gray-50">
|
||||
<ModalButton
|
||||
variant="secondary"
|
||||
className="bg-white shadow-md"
|
||||
onClick={handleCancel}
|
||||
disabled={isLoading}
|
||||
>
|
||||
Batal
|
||||
</ModalButton>
|
||||
<ModalButton
|
||||
variant="primary"
|
||||
onClick={handleSave}
|
||||
disabled={isLoading}
|
||||
loading={isLoading}
|
||||
>
|
||||
{isLoading ? 'Menyimpan...' : 'Simpan'}
|
||||
</ModalButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
import { FC, useState, useEffect } from 'react';
|
||||
import { PlusOutlined, DeleteOutlined } from '@ant-design/icons';
|
||||
import { ModalButton } from '../buttons/modal-button';
|
||||
import { InputField } from '@imphnen-frontend-service/ui/molecules';
|
||||
|
||||
|
||||
interface Experience {
|
||||
id: string;
|
||||
company: string;
|
||||
position: string;
|
||||
duration: string;
|
||||
period: string;
|
||||
}
|
||||
|
||||
interface ExperienceModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
initialValue: Experience[];
|
||||
onSave: (value: Experience[]) => Promise<void>;
|
||||
isLoading?: boolean;
|
||||
showNotification?: (type: 'success' | 'error', title: string, message?: string) => void;
|
||||
}
|
||||
|
||||
export const ExperienceModal: FC<ExperienceModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
initialValue,
|
||||
onSave,
|
||||
isLoading = false,
|
||||
showNotification,
|
||||
}) => {
|
||||
const [experiences, setExperiences] = useState<Experience[]>(initialValue);
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
setExperiences(initialValue);
|
||||
}, [initialValue]);
|
||||
|
||||
const handleSave = async () => {
|
||||
|
||||
const hasEmpty = experiences.some(exp =>
|
||||
!exp.company.trim() || !exp.position.trim() || !exp.duration.trim() || !exp.period.trim()
|
||||
);
|
||||
if (hasEmpty) {
|
||||
if (showNotification) {
|
||||
showNotification('error', 'Data Tidak Lengkap', 'Semua field harus diisi pada setiap pengalaman kerja.');
|
||||
} else {
|
||||
alert('Semua field harus diisi pada setiap pengalaman kerja.');
|
||||
}
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await onSave(experiences);
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error('Save failed:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
setExperiences(initialValue);
|
||||
onClose();
|
||||
};
|
||||
|
||||
const addExperience = () => {
|
||||
const newExperience: Experience = {
|
||||
id: Date.now().toString(),
|
||||
company: '',
|
||||
position: '',
|
||||
duration: '',
|
||||
period: '',
|
||||
};
|
||||
setExperiences([...experiences, newExperience]);
|
||||
};
|
||||
|
||||
const removeExperience = (id: string) => {
|
||||
setExperiences(experiences.filter(exp => exp.id !== id));
|
||||
};
|
||||
|
||||
const updateExperience = (id: string, field: keyof Experience, value: string) => {
|
||||
setExperiences(experiences.map(exp =>
|
||||
exp.id === id ? { ...exp, [field]: value } : exp
|
||||
));
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<button
|
||||
className="absolute inset-0 bg-black/20 backdrop-blur-sm"
|
||||
onClick={handleCancel}
|
||||
aria-label="Close modal"
|
||||
/>
|
||||
|
||||
<div className="relative bg-white rounded-xl shadow-xl max-w-5xl w-full max-h-[95vh] overflow-hidden">
|
||||
<div className="p-6 border-b border-gray-200">
|
||||
<div className="flex">
|
||||
<h2 className="text-xl font-semibold text-gray-900 px-3 py-1 bg-[#23A1EB]/10 rounded-md flex-1">Edit Experience</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-6 max-h-[60vh] overflow-y-auto">
|
||||
<div className="space-y-6">
|
||||
{experiences.map((experience, index) => (
|
||||
<div key={experience.id} className="border border-gray-200 rounded-lg p-4">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-medium text-gray-900">Experience {index + 1}</h3>
|
||||
<ModalButton
|
||||
variant="danger"
|
||||
size="sm"
|
||||
onClick={() => removeExperience(experience.id)}
|
||||
className="text-red-500 hover:text-red-700"
|
||||
>
|
||||
<DeleteOutlined />
|
||||
</ModalButton>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<InputField
|
||||
label="Company"
|
||||
value={experience.company}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => updateExperience(experience.id, 'company', e.target.value)}
|
||||
placeholder="Enter company name"
|
||||
/>
|
||||
<InputField
|
||||
label="Position"
|
||||
value={experience.position}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => updateExperience(experience.id, 'position', e.target.value)}
|
||||
placeholder="Enter position"
|
||||
/>
|
||||
<InputField
|
||||
label="Duration"
|
||||
value={experience.duration}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => updateExperience(experience.id, 'duration', e.target.value)}
|
||||
placeholder="e.g., 7 Months"
|
||||
/>
|
||||
<InputField
|
||||
label="Period"
|
||||
value={experience.period}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => updateExperience(experience.id, 'period', e.target.value)}
|
||||
placeholder="e.g., Jan 2024 - Present"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<ModalButton
|
||||
variant="secondary"
|
||||
onClick={addExperience}
|
||||
className="w-full border-2 border-dashed border-gray-300 rounded-lg p-4 text-gray-500 hover:border-[#23A1EB] hover:text-[#23A1EB] transition-colors flex items-center justify-center gap-2"
|
||||
>
|
||||
<PlusOutlined />
|
||||
Add Experience
|
||||
</ModalButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end gap-3 p-6 border-t border-gray-200 bg-gray-50">
|
||||
<ModalButton
|
||||
variant="secondary"
|
||||
className="bg-white shadow-md"
|
||||
onClick={handleCancel}
|
||||
disabled={isLoading}
|
||||
>
|
||||
Batal
|
||||
</ModalButton>
|
||||
<ModalButton
|
||||
variant="primary"
|
||||
onClick={handleSave}
|
||||
disabled={isLoading}
|
||||
loading={isLoading}
|
||||
>
|
||||
{isLoading ? 'Menyimpan...' : 'Simpan'}
|
||||
</ModalButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
export { NotificationModal } from './notification-modal';
|
||||
export { CVModal } from './cv-modal';
|
||||
export { DescriptionModal } from './description-modal';
|
||||
export { EducationModal } from './education-modal';
|
||||
export { ExperienceModal } from './experience-modal';
|
||||
export { SocialMediaModal } from './social-media-modal';
|
||||
export { SkillsModal } from './skills-modal';
|
||||
export { EditProfileModal } from './edit-profile-modal';
|
||||
export { LanguagesModal } from './languages-modal';
|
||||
export { PersonalInfoModal } from './personal-info-modal';
|
||||
@@ -0,0 +1,153 @@
|
||||
import { FC, useState, useEffect } from 'react';
|
||||
import { Input } from '@imphnen-frontend-service/ui/atoms';
|
||||
import { ModalButton } from '../buttons/modal-button';
|
||||
|
||||
interface Language {
|
||||
id?: string;
|
||||
name: string;
|
||||
level: string;
|
||||
}
|
||||
|
||||
interface LanguagesModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
initialValue: Language[];
|
||||
onSave: (languages: Language[]) => void;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
export const LanguagesModal: FC<LanguagesModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
initialValue,
|
||||
onSave,
|
||||
isLoading = false,
|
||||
}) => {
|
||||
const [languages, setLanguages] = useState<Language[]>(initialValue);
|
||||
|
||||
useEffect(() => {
|
||||
const languagesWithIds = initialValue.map(lang => ({
|
||||
...lang,
|
||||
id: lang.id || `lang-${Date.now()}-${Math.random()}`
|
||||
}));
|
||||
setLanguages(languagesWithIds);
|
||||
}, [initialValue]);
|
||||
|
||||
const handleAddLanguage = () => {
|
||||
setLanguages([...languages, {
|
||||
id: `lang-${Date.now()}-${Math.random()}`,
|
||||
name: '',
|
||||
level: ''
|
||||
}]);
|
||||
};
|
||||
|
||||
const handleLanguageChange = (index: number, field: keyof Language, value: string) => {
|
||||
const newLanguages = [...languages];
|
||||
newLanguages[index] = { ...newLanguages[index], [field]: value };
|
||||
setLanguages(newLanguages);
|
||||
};
|
||||
|
||||
const handleRemoveLanguage = (index: number) => {
|
||||
const newLanguages = languages.filter((_, i) => i !== index);
|
||||
setLanguages(newLanguages);
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
|
||||
const languagesToSave = languages.map(({ id, ...lang }) => lang);
|
||||
onSave(languagesToSave);
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
const languagesWithIds = initialValue.map(lang => ({
|
||||
...lang,
|
||||
id: lang.id || `lang-${Date.now()}-${Math.random()}`
|
||||
}));
|
||||
setLanguages(languagesWithIds);
|
||||
onClose();
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
{}
|
||||
<button
|
||||
className="absolute inset-0 bg-black/20 backdrop-blur-sm"
|
||||
onClick={handleCancel}
|
||||
type="button"
|
||||
aria-label="Close modal"
|
||||
/>
|
||||
|
||||
{}
|
||||
<div className="relative bg-white rounded-xl shadow-xl w-full max-w-5xl mx-auto max-h-[95vh] overflow-hidden">
|
||||
{}
|
||||
<div className="p-6 pb-4 border-b border-gray-200">
|
||||
<div className="flex">
|
||||
<h2 className="text-xl font-semibold text-gray-900 px-3 py-1 bg-[#23A1EB]/10 rounded-md flex-1">Edit Languages</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{}
|
||||
<div className="p-6 max-h-[60vh] overflow-y-auto">
|
||||
<div className="space-y-4">
|
||||
{languages.map((language, index) => (
|
||||
<div key={language.id} className="flex flex-col sm:flex-row sm:items-end gap-3 sm:gap-2">
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium text-gray-700 mb-1">Language Name</p>
|
||||
<Input
|
||||
value={language.name}
|
||||
onChange={(e) => handleLanguageChange(index, 'name', e.target.value)}
|
||||
placeholder="e.g., English"
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium text-gray-700 mb-1">Level</p>
|
||||
<Input
|
||||
value={language.level}
|
||||
onChange={(e) => handleLanguageChange(index, 'level', e.target.value)}
|
||||
placeholder="e.g., Fluent"
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
<div className="sm:flex-shrink-0">
|
||||
<ModalButton variant="danger" onClick={() => handleRemoveLanguage(index)} className="w-full sm:w-auto">
|
||||
Remove
|
||||
</ModalButton>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<ModalButton variant="secondary" onClick={handleAddLanguage} className="w-full">
|
||||
Add Language
|
||||
</ModalButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{}
|
||||
<div className="flex gap-3 p-6 pt-4">
|
||||
<ModalButton
|
||||
variant="secondary"
|
||||
onClick={handleCancel}
|
||||
className="flex-1 bg-white shadow-md"
|
||||
disabled={isLoading}
|
||||
>
|
||||
Cancel
|
||||
</ModalButton>
|
||||
<ModalButton
|
||||
variant="primary"
|
||||
onClick={handleSave}
|
||||
className="flex-1"
|
||||
disabled={isLoading}
|
||||
loading={isLoading}
|
||||
>
|
||||
{isLoading ? 'Saving...' : 'Save'}
|
||||
</ModalButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import { FC } from 'react';
|
||||
import { CloseOutlined } from '@ant-design/icons';
|
||||
import { ModalButton } from '../buttons/modal-button';
|
||||
|
||||
export type NotificationType = {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
type: 'success' | 'error';
|
||||
title: string;
|
||||
message?: string;
|
||||
header: string;
|
||||
}
|
||||
|
||||
interface NotificationModalProps extends NotificationType {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
type: 'success' | 'error';
|
||||
title: string;
|
||||
message?: string;
|
||||
header: string;
|
||||
}
|
||||
|
||||
export const NotificationModal: FC<NotificationModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
type,
|
||||
title,
|
||||
message,
|
||||
header,
|
||||
}) => {
|
||||
if (!isOpen) return null;
|
||||
|
||||
const isSuccess = type === 'success';
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[9999] flex items-center justify-center p-4">
|
||||
<button
|
||||
className="absolute inset-0 bg-black/20 backdrop-blur-sm"
|
||||
onClick={onClose}
|
||||
aria-label="Close modal"
|
||||
/>
|
||||
|
||||
<div className="relative bg-white rounded-xl shadow-xl max-w-md w-full overflow-hidden">
|
||||
<div className="p-6 pb-4 border-b border-gray-200">
|
||||
<div className="flex">
|
||||
<h2 className="text-xl font-semibold text-gray-900 px-3 py-1 bg-[#23A1EB]/10 rounded-md flex-1">{header}</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-8 text-center">
|
||||
|
||||
<div className="mb-6">
|
||||
{isSuccess ? (
|
||||
<div className="w-30 h-30 mx-auto mb-4">
|
||||
<img
|
||||
src="/image/success.png"
|
||||
alt="Success"
|
||||
width={80}
|
||||
height={80}
|
||||
className="w-full h-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-20 h-20 bg-red-500 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<CloseOutlined className="text-white text-3xl" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<h2 className={`text-lg font-medium ${isSuccess ? 'text-green-600' : 'text-red-600'}`}>
|
||||
{title}
|
||||
</h2>
|
||||
{!isSuccess && message && (
|
||||
<div className="mt-2 text-sm text-red-500 whitespace-pre-line">{message}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ModalButton
|
||||
variant="primary"
|
||||
onClick={onClose}
|
||||
className="w-full bg-[#23A1EB] hover:bg-[#23A1EB]/90"
|
||||
>
|
||||
Selesai
|
||||
</ModalButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
import { FC, useState, useEffect } from 'react';
|
||||
import { ModalButton } from '../buttons/modal-button';
|
||||
|
||||
interface PersonalInfo {
|
||||
email: string;
|
||||
phone: string;
|
||||
location: string;
|
||||
}
|
||||
|
||||
interface PersonalInfoModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
initialValue: PersonalInfo;
|
||||
onSave: (value: PersonalInfo) => Promise<void>;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
export const PersonalInfoModal: FC<PersonalInfoModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
initialValue,
|
||||
onSave,
|
||||
isLoading = false,
|
||||
}) => {
|
||||
const [personalInfo, setPersonalInfo] = useState(initialValue);
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
setPersonalInfo(initialValue);
|
||||
}, [initialValue]);
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
await onSave(personalInfo);
|
||||
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error('Save failed:', error);
|
||||
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
setPersonalInfo(initialValue);
|
||||
onClose();
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
|
||||
<button
|
||||
className="absolute inset-0 bg-black/20 backdrop-blur-sm"
|
||||
onClick={handleCancel}
|
||||
aria-label="Close modal"
|
||||
type="button"
|
||||
/>
|
||||
|
||||
|
||||
<div className="relative bg-white rounded-xl shadow-xl w-full max-w-5xl mx-auto">
|
||||
|
||||
<div className="p-6 pb-4 border-b border-gray-200">
|
||||
<div className="flex">
|
||||
<h2 className="text-xl font-semibold text-gray-900 px-3 py-1 bg-[#23A1EB]/10 rounded-md flex-1">Edit Personal Information</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="px-6 py-4 space-y-4">
|
||||
<div>
|
||||
<label htmlFor="personal-email" className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
id="personal-email"
|
||||
type="email"
|
||||
value={personalInfo.email}
|
||||
onChange={(e) => setPersonalInfo({ ...personalInfo, email: e.target.value })}
|
||||
placeholder="Enter your email"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#23A1EB] focus:border-[#23A1EB] outline-none transition-colors"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="personal-phone" className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Phone
|
||||
</label>
|
||||
<input
|
||||
id="personal-phone"
|
||||
type="tel"
|
||||
value={personalInfo.phone}
|
||||
onChange={(e) => setPersonalInfo({ ...personalInfo, phone: e.target.value })}
|
||||
placeholder="Enter your phone number"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#23A1EB] focus:border-[#23A1EB] outline-none transition-colors"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="personal-location" className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Location
|
||||
</label>
|
||||
<input
|
||||
id="personal-location"
|
||||
type="text"
|
||||
value={personalInfo.location}
|
||||
onChange={(e) => setPersonalInfo({ ...personalInfo, location: e.target.value })}
|
||||
placeholder="Enter your location"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#23A1EB] focus:border-[#23A1EB] outline-none transition-colors"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="flex gap-3 p-6 pt-4">
|
||||
<ModalButton variant="secondary"
|
||||
onClick={handleCancel}
|
||||
className="flex-1 bg-white shadow-md"
|
||||
disabled={isLoading}
|
||||
>
|
||||
Batal
|
||||
</ModalButton>
|
||||
<ModalButton variant="primary"
|
||||
onClick={handleSave}
|
||||
className="flex-1"
|
||||
disabled={isLoading}
|
||||
loading={isLoading}
|
||||
>
|
||||
{isLoading ? 'Menyimpan...' : 'Simpan'}
|
||||
</ModalButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
+105
@@ -0,0 +1,105 @@
|
||||
import { FC, useState } from 'react';
|
||||
import { ModalButton } from '../buttons/modal-button';
|
||||
|
||||
interface ProfileBasicInfo {
|
||||
name: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
interface ProfileBasicInfoModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
initialValue: ProfileBasicInfo;
|
||||
onSave: (value: ProfileBasicInfo) => void;
|
||||
}
|
||||
|
||||
export const ProfileBasicInfoModal: FC<ProfileBasicInfoModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
initialValue,
|
||||
onSave,
|
||||
}) => {
|
||||
const [profileInfo, setProfileInfo] = useState(initialValue);
|
||||
|
||||
const handleSave = () => {
|
||||
onSave(profileInfo);
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
setProfileInfo(initialValue);
|
||||
onClose();
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
|
||||
<button
|
||||
className="absolute inset-0 bg-black/20 backdrop-blur-sm"
|
||||
onClick={handleCancel}
|
||||
aria-label="Close modal"
|
||||
type="button"
|
||||
/>
|
||||
|
||||
|
||||
<div className="relative bg-white rounded-xl shadow-xl w-full max-w-5xl mx-auto">
|
||||
|
||||
<div className="p-6 pb-4 border-b border-gray-200">
|
||||
<div className="flex">
|
||||
<h2 className="text-xl font-semibold text-gray-900 px-3 py-1 bg-[#23A1EB]/10 rounded-md flex-1">Edit Profile</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="px-6 py-4 space-y-4">
|
||||
<div>
|
||||
<label htmlFor="profile-name" className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Full Name
|
||||
</label>
|
||||
<input
|
||||
id="profile-name"
|
||||
type="text"
|
||||
value={profileInfo.name}
|
||||
onChange={(e) => setProfileInfo({ ...profileInfo, name: e.target.value })}
|
||||
placeholder="Enter your full name"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#23A1EB] focus:border-[#23A1EB] outline-none transition-colors"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="profile-title" className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Professional Title
|
||||
</label>
|
||||
<input
|
||||
id="profile-title"
|
||||
type="text"
|
||||
value={profileInfo.title}
|
||||
onChange={(e) => setProfileInfo({ ...profileInfo, title: e.target.value })}
|
||||
placeholder="Enter your professional title"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#23A1EB] focus:border-[#23A1EB] outline-none transition-colors"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-3 p-6 pt-4">
|
||||
<ModalButton
|
||||
variant="secondary"
|
||||
onClick={handleCancel}
|
||||
className="flex-1"
|
||||
>
|
||||
Batal
|
||||
</ModalButton>
|
||||
<ModalButton
|
||||
variant="primary"
|
||||
onClick={handleSave}
|
||||
className="flex-1"
|
||||
>
|
||||
Simpan
|
||||
</ModalButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
import { FC, useState, useEffect } from 'react';
|
||||
import { PlusOutlined, DeleteOutlined } from '@ant-design/icons';
|
||||
import { ModalButton } from '../buttons/modal-button';
|
||||
import { InputField } from '@imphnen-frontend-service/ui/molecules';
|
||||
|
||||
interface Skill {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface SkillsModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
initialValue: Skill[];
|
||||
onSave: (value: Skill[]) => Promise<void>;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
export const SkillsModal: FC<SkillsModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
initialValue,
|
||||
onSave,
|
||||
isLoading = false,
|
||||
}) => {
|
||||
const [skills, setSkills] = useState<Skill[]>(initialValue);
|
||||
const [newSkill, setNewSkill] = useState({ name: '' });
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
setSkills(initialValue);
|
||||
}, [initialValue]);
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
await onSave(skills);
|
||||
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error('Save failed:', error);
|
||||
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
setSkills(initialValue);
|
||||
setNewSkill({ name: '' });
|
||||
onClose();
|
||||
};
|
||||
|
||||
const addSkill = () => {
|
||||
if (newSkill.name.trim()) {
|
||||
const skill: Skill = {
|
||||
id: Date.now().toString(),
|
||||
name: newSkill.name.trim(),
|
||||
};
|
||||
setSkills([...skills, skill]);
|
||||
setNewSkill({ name: '' });
|
||||
}
|
||||
};
|
||||
|
||||
const removeSkill = (id: string) => {
|
||||
setSkills(skills.filter(skill => skill.id !== id));
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
|
||||
<button
|
||||
className="absolute inset-0 bg-black/20 backdrop-blur-sm"
|
||||
onClick={handleCancel}
|
||||
type="button"
|
||||
aria-label="Close modal"
|
||||
/>
|
||||
|
||||
|
||||
<div className="relative bg-white rounded-xl shadow-xl w-full max-w-5xl mx-auto max-h-[95vh] overflow-hidden">
|
||||
|
||||
<div className="p-6 pb-4 border-b border-gray-200">
|
||||
<div className="flex">
|
||||
<h2 className="text-xl font-semibold text-gray-900 px-3 py-1 bg-[#23A1EB]/10 rounded-md flex-1">Edit Skills</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="p-6 max-h-[60vh] overflow-y-auto">
|
||||
|
||||
<div className="mb-6 p-4 border border-gray-200 rounded-lg bg-gray-50">
|
||||
<h3 className="text-sm font-medium text-gray-700 mb-3">Add New Skill</h3>
|
||||
<div className="flex flex-col sm:flex-row gap-3">
|
||||
<div className="flex-1">
|
||||
<InputField
|
||||
label="Skill Name"
|
||||
value={newSkill.name}
|
||||
onChange={(e) => setNewSkill({ ...newSkill, name: e.target.value })}
|
||||
placeholder="e.g., React, JavaScript, etc."
|
||||
/>
|
||||
</div>
|
||||
<div className="flex sm:items-end">
|
||||
<ModalButton
|
||||
onClick={addSkill}
|
||||
variant="primary"
|
||||
size="sm"
|
||||
className="w-full sm:w-auto"
|
||||
>
|
||||
<PlusOutlined />
|
||||
<span className="sm:hidden ml-2">Add Skill</span>
|
||||
</ModalButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-gray-700 mb-3">Current Skills</h3>
|
||||
{skills.length === 0 ? (
|
||||
<p className="text-gray-500 text-sm py-4">No skills added yet.</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{skills.map((skill) => (
|
||||
<div
|
||||
key={skill.id}
|
||||
className="flex items-center justify-between p-3 border border-gray-200 rounded-md bg-white"
|
||||
>
|
||||
<div>
|
||||
<span className="font-medium text-gray-900">{skill.name}</span>
|
||||
</div>
|
||||
<ModalButton
|
||||
onClick={() => removeSkill(skill.id)}
|
||||
variant="danger"
|
||||
size="sm"
|
||||
className="text-red-500 hover:text-red-700"
|
||||
>
|
||||
<DeleteOutlined />
|
||||
</ModalButton>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="flex gap-3 p-6 pt-4">
|
||||
<ModalButton variant="secondary"
|
||||
onClick={handleCancel}
|
||||
className="flex-1"
|
||||
disabled={isLoading}
|
||||
>
|
||||
Batal
|
||||
</ModalButton>
|
||||
<ModalButton variant="primary"
|
||||
onClick={handleSave}
|
||||
className="flex-1"
|
||||
disabled={isLoading}
|
||||
loading={isLoading}
|
||||
>
|
||||
{isLoading ? 'Menyimpan...' : 'Simpan'}
|
||||
</ModalButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
import { FC, useState, useEffect } from 'react';
|
||||
import { ModalButton } from '../buttons/modal-button';
|
||||
|
||||
interface SocialLink {
|
||||
platform: string;
|
||||
placeholder: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
interface SocialMediaModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
initialValue: SocialLink[];
|
||||
onSave: (value: SocialLink[]) => Promise<void>;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
export const SocialMediaModal: FC<SocialMediaModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
initialValue,
|
||||
onSave,
|
||||
isLoading = false,
|
||||
}) => {
|
||||
const [socialLinks, setSocialLinks] = useState<SocialLink[]>(initialValue);
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
setSocialLinks(initialValue);
|
||||
}, [initialValue]);
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
await onSave(socialLinks);
|
||||
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error('Save failed:', error);
|
||||
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
setSocialLinks(initialValue);
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleSocialLinkChange = (index: number, value: string) => {
|
||||
const updated = [...socialLinks];
|
||||
updated[index].value = value;
|
||||
setSocialLinks(updated);
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
|
||||
<button
|
||||
className="absolute inset-0 bg-black/20 backdrop-blur-sm"
|
||||
onClick={handleCancel}
|
||||
aria-label="Close modal"
|
||||
type="button"
|
||||
/>
|
||||
|
||||
|
||||
<div className="relative bg-white rounded-xl shadow-xl w-full max-w-5xl mx-auto">
|
||||
|
||||
<div className="p-6 pb-4 border-b border-gray-200">
|
||||
<div className="flex">
|
||||
<h2 className="text-xl font-semibold text-gray-900 px-3 py-1 bg-[#23A1EB]/10 rounded-md flex-1">Edit Social Media</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="px-6 py-4 space-y-4 max-h-[60vh] overflow-y-auto">
|
||||
{socialLinks.map((link, index) => (
|
||||
<div key={link.platform}>
|
||||
<label htmlFor={`social-${index}`} className="block text-sm font-medium text-gray-700 mb-2">
|
||||
{link.platform}
|
||||
</label>
|
||||
<input
|
||||
id={`social-${index}`}
|
||||
type="text"
|
||||
placeholder={link.placeholder}
|
||||
value={link.value}
|
||||
onChange={(e) => handleSocialLinkChange(index, e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#23A1EB] focus:border-[#23A1EB] outline-none transition-colors"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
|
||||
<div className="flex gap-3 p-6 pt-4">
|
||||
<ModalButton
|
||||
variant="secondary"
|
||||
onClick={handleCancel}
|
||||
className="flex-1"
|
||||
disabled={isLoading}
|
||||
>
|
||||
Batal
|
||||
</ModalButton>
|
||||
<ModalButton
|
||||
variant="primary"
|
||||
onClick={handleSave}
|
||||
className="flex-1"
|
||||
disabled={isLoading}
|
||||
loading={isLoading}
|
||||
>
|
||||
{isLoading ? 'Menyimpan...' : 'Simpan'}
|
||||
</ModalButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
export { ProfileHeader } from './profile-header';
|
||||
export { ProfileInfo } from './profile-info';
|
||||
export { ProfileTabs } from './profile-tabs';
|
||||
export { ProfileForm } from './profile-form';
|
||||
export { ProfileSidebar } from './profile-sidebar';
|
||||
export type { SocialLink, Experience, Education, Language, PersonalInfo, ContactInfo, CvResume, NotificationState, ProfileFormProps } from './profile-form-types';
|
||||
export { useProfileFormState, useProfileDataSync } from './profile-form-hooks';
|
||||
export { useProfileHandlers } from './profile-form-handlers';
|
||||
+120
@@ -0,0 +1,120 @@
|
||||
import type { MentorUpdateRequestDto, UserUpdateRequestDto } from '@imphnen-frontend-service/service';
|
||||
import { useProfile } from '../contexts/profile-context';
|
||||
import type { SocialLink, ProfileUpdateData, Experience, Education } from './profile-form-types';
|
||||
|
||||
export const useProfileHandlers = (
|
||||
showNotification: (type: 'success' | 'error', title: string, message?: string) => void
|
||||
) => {
|
||||
const { updateProfile, profileType } = useProfile();
|
||||
|
||||
const handleProfileUpdate = async (updates: ProfileUpdateData) => {
|
||||
try {
|
||||
const result = await updateProfile(updates);
|
||||
showNotification('success', 'Perubahan Berhasil Disimpan');
|
||||
return result;
|
||||
} catch (err) {
|
||||
console.error('Profile update error:', err);
|
||||
showNotification('error', 'Gagal menyimpan perubahan', 'Silakan coba lagi');
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
const handlePersonalInfoSave = async (personalData: { phone: string; location: string }) => {
|
||||
await handleProfileUpdate({
|
||||
phone_for_verification: personalData.phone,
|
||||
location: personalData.location
|
||||
});
|
||||
};
|
||||
|
||||
const handleContactInfoSave = async (contactData: { phone: string; location: string }) => {
|
||||
await handleProfileUpdate({
|
||||
phone_for_verification: contactData.phone,
|
||||
location: contactData.location
|
||||
});
|
||||
};
|
||||
|
||||
const handleSocialMediaSave = async (newSocialLinks: SocialLink[]) => {
|
||||
const linkedIn = newSocialLinks.find(link => link.platform === 'LinkedIn')?.value;
|
||||
const github = newSocialLinks.find(link => link.platform === 'Github')?.value;
|
||||
const portfolio = newSocialLinks.find(link => link.platform === 'Portfolio')?.value;
|
||||
const twitter = newSocialLinks.find(link => link.platform === 'Twitter')?.value;
|
||||
|
||||
const updates: ProfileUpdateData = {
|
||||
linkedin_url: linkedIn || undefined,
|
||||
github_url: github || undefined,
|
||||
twitter_url: twitter || undefined,
|
||||
};
|
||||
|
||||
if (profileType === 'mentor') {
|
||||
(updates as MentorUpdateRequestDto).portfolio_url = portfolio || undefined;
|
||||
} else if (profileType === 'user') {
|
||||
(updates as UserUpdateRequestDto).website_url = portfolio || undefined;
|
||||
}
|
||||
|
||||
await handleProfileUpdate(updates);
|
||||
};
|
||||
|
||||
const handleDescriptionSave = async (newDescription: string) => {
|
||||
await handleProfileUpdate({
|
||||
bio: newDescription || null
|
||||
});
|
||||
};
|
||||
|
||||
const handleSkillsSave = async (newSkills: string[]) => {
|
||||
const updates: ProfileUpdateData = {};
|
||||
|
||||
if (profileType === 'user') {
|
||||
(updates as UserUpdateRequestDto).skills = newSkills;
|
||||
} else if (profileType === 'mentor') {
|
||||
(updates as MentorUpdateRequestDto).expertise = newSkills;
|
||||
}
|
||||
|
||||
await handleProfileUpdate(updates);
|
||||
};
|
||||
|
||||
const handleLanguagesSave = async (newLanguages: Array<{ name: string; level: string }>) => {
|
||||
await handleProfileUpdate({
|
||||
languages: newLanguages.map(lang => lang.name)
|
||||
} as MentorUpdateRequestDto | UserUpdateRequestDto);
|
||||
};
|
||||
|
||||
const handleExperiencesSave = async (newExperiences: Experience[]) => {
|
||||
try {
|
||||
await handleProfileUpdate({
|
||||
experience: newExperiences
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Experience update error:', error);
|
||||
|
||||
}
|
||||
};
|
||||
|
||||
const handleEducationSave = async (newEducations: Education[]) => {
|
||||
try {
|
||||
await handleProfileUpdate({
|
||||
education: newEducations
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Education update error:', error);
|
||||
|
||||
}
|
||||
};
|
||||
|
||||
const handleCvResumeSave = async (cvData: { fileName?: string; fileUrl?: string }) => {
|
||||
await handleProfileUpdate({
|
||||
cv_url: cvData.fileUrl || cvData.fileName || null
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
handlePersonalInfoSave,
|
||||
handleContactInfoSave,
|
||||
handleSocialMediaSave,
|
||||
handleDescriptionSave,
|
||||
handleSkillsSave,
|
||||
handleLanguagesSave,
|
||||
handleExperiencesSave,
|
||||
handleEducationSave,
|
||||
handleCvResumeSave
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,253 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useProfile } from '../contexts/profile-context';
|
||||
import type {
|
||||
SocialLink,
|
||||
Experience,
|
||||
Education,
|
||||
Language,
|
||||
PersonalInfo,
|
||||
ContactInfo,
|
||||
CvResume,
|
||||
NotificationState
|
||||
} from './profile-form-types';
|
||||
|
||||
export const useProfileFormState = () => {
|
||||
const { profileData, profileType } = useProfile();
|
||||
|
||||
const [notification, setNotification] = useState<NotificationState>({
|
||||
isOpen: false,
|
||||
type: 'success',
|
||||
title: '',
|
||||
message: ''
|
||||
});
|
||||
|
||||
const [socialLinks, setSocialLinks] = useState<SocialLink[]>([
|
||||
{
|
||||
platform: 'LinkedIn',
|
||||
placeholder: 'linkedin.com/in/yourprofile',
|
||||
value: ''
|
||||
},
|
||||
{
|
||||
platform: 'Github',
|
||||
placeholder: 'github.com/yourusername',
|
||||
value: ''
|
||||
},
|
||||
{
|
||||
platform: 'Portfolio',
|
||||
placeholder: 'yourportfolio.com',
|
||||
value: ''
|
||||
},
|
||||
{
|
||||
platform: 'Twitter',
|
||||
placeholder: 'twitter.com/yourusername',
|
||||
value: ''
|
||||
}
|
||||
]);
|
||||
|
||||
const [experiences, setExperiences] = useState<Experience[]>([]);
|
||||
const [education, setEducation] = useState<Education[]>([]);
|
||||
const [skills, setSkills] = useState<string[]>([]);
|
||||
const [languages, setLanguages] = useState<Language[]>([]);
|
||||
|
||||
const [personalInfo, setPersonalInfo] = useState<PersonalInfo>({
|
||||
fullname: '',
|
||||
title: '',
|
||||
bio: '',
|
||||
birthdate: '',
|
||||
gender: ''
|
||||
});
|
||||
|
||||
const [contactInfo, setContactInfo] = useState<ContactInfo>({
|
||||
email: '',
|
||||
phone: '',
|
||||
location: ''
|
||||
});
|
||||
|
||||
const [cvResume, setCvResume] = useState<CvResume>({
|
||||
cvUrl: '',
|
||||
resumeUrl: ''
|
||||
});
|
||||
|
||||
return {
|
||||
profileData,
|
||||
profileType,
|
||||
notification,
|
||||
setNotification,
|
||||
socialLinks,
|
||||
setSocialLinks,
|
||||
experiences,
|
||||
setExperiences,
|
||||
education,
|
||||
setEducation,
|
||||
skills,
|
||||
setSkills,
|
||||
languages,
|
||||
setLanguages,
|
||||
personalInfo,
|
||||
setPersonalInfo,
|
||||
contactInfo,
|
||||
setContactInfo,
|
||||
cvResume,
|
||||
setCvResume
|
||||
};
|
||||
};
|
||||
|
||||
export const useProfileDataSync = (state: ReturnType<typeof useProfileFormState>) => {
|
||||
const {
|
||||
profileData,
|
||||
profileType,
|
||||
setSocialLinks,
|
||||
setExperiences,
|
||||
setEducation,
|
||||
setSkills,
|
||||
setLanguages,
|
||||
setPersonalInfo,
|
||||
setContactInfo,
|
||||
setCvResume
|
||||
} = state;
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
if (profileData) {
|
||||
const linkedinUrl = 'linkedin_url' in profileData ? profileData.linkedin_url || '' : '';
|
||||
const githubUrl = 'github_url' in profileData ? profileData.github_url || '' : '';
|
||||
|
||||
|
||||
let portfolioUrl = '';
|
||||
if (profileType === 'mentor' && 'portfolio_url' in profileData) {
|
||||
portfolioUrl = profileData.portfolio_url || '';
|
||||
} else if (profileType === 'user' && 'website_url' in profileData) {
|
||||
portfolioUrl = profileData.website_url || '';
|
||||
}
|
||||
|
||||
const twitterUrl = 'twitter_url' in profileData ? profileData.twitter_url || '' : '';
|
||||
|
||||
setSocialLinks([
|
||||
{
|
||||
platform: 'LinkedIn',
|
||||
placeholder: 'linkedin.com/in/yourprofile',
|
||||
value: linkedinUrl
|
||||
},
|
||||
{
|
||||
platform: 'Github',
|
||||
placeholder: 'github.com/yourusername',
|
||||
value: githubUrl
|
||||
},
|
||||
{
|
||||
platform: 'Portfolio',
|
||||
placeholder: 'yourportfolio.com',
|
||||
value: portfolioUrl
|
||||
},
|
||||
{
|
||||
platform: 'Twitter',
|
||||
placeholder: 'twitter.com/yourusername',
|
||||
value: twitterUrl
|
||||
}
|
||||
]);
|
||||
}
|
||||
}, [profileData, profileType, setSocialLinks]);
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
if (profileData) {
|
||||
const experiences = 'experience' in profileData ? profileData.experience || [] : [];
|
||||
setExperiences(experiences);
|
||||
}
|
||||
}, [profileData, setExperiences]);
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
if (profileData) {
|
||||
const education = 'education' in profileData ? profileData.education || [] : [];
|
||||
setEducation(education);
|
||||
}
|
||||
}, [profileData, setEducation]);
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
if (profileData) {
|
||||
let skills: string[] = [];
|
||||
if ('skills' in profileData) {
|
||||
skills = profileData.skills || [];
|
||||
} else if ('expertise' in profileData) {
|
||||
skills = profileData.expertise || [];
|
||||
}
|
||||
setSkills(skills);
|
||||
}
|
||||
}, [profileData, setSkills]);
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
if (profileData) {
|
||||
const languages: Language[] = 'languages' in profileData
|
||||
? (profileData.languages || []).map(lang => ({ name: lang, level: 'Intermediate' }))
|
||||
: [];
|
||||
setLanguages(languages);
|
||||
}
|
||||
}, [profileData, setLanguages]);
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
if (profileData) {
|
||||
const bio = 'bio' in profileData ? profileData.bio || '' : '';
|
||||
|
||||
let fullname = '';
|
||||
if ('fullname' in profileData) {
|
||||
fullname = profileData.fullname || '';
|
||||
} else if ('legal_name' in profileData) {
|
||||
fullname = profileData.legal_name || '';
|
||||
}
|
||||
|
||||
const title = 'current_role' in profileData ? profileData.current_role || '' : '';
|
||||
const birthdate = 'birthdate' in profileData ? profileData.birthdate || '' : '';
|
||||
const gender = 'gender' in profileData ? profileData.gender || '' : '';
|
||||
|
||||
setPersonalInfo({
|
||||
fullname,
|
||||
title,
|
||||
bio,
|
||||
birthdate,
|
||||
gender
|
||||
});
|
||||
}
|
||||
}, [profileData, setPersonalInfo]);
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
if (profileData) {
|
||||
const email = 'email' in profileData ? profileData.email || '' : '';
|
||||
|
||||
let phone = '';
|
||||
if ('phone_number' in profileData) {
|
||||
phone = profileData.phone_number || '';
|
||||
} else if ('phone_for_verification' in profileData) {
|
||||
phone = profileData.phone_for_verification || '';
|
||||
}
|
||||
|
||||
let location = '';
|
||||
if ('location' in profileData) {
|
||||
location = profileData.location || '';
|
||||
} else if ('domicile' in profileData) {
|
||||
location = profileData.domicile || '';
|
||||
}
|
||||
|
||||
setContactInfo({
|
||||
email,
|
||||
phone,
|
||||
location
|
||||
});
|
||||
}
|
||||
}, [profileData, setContactInfo]);
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
if (profileData) {
|
||||
const cvUrl = profileType === 'mentor' && 'cv_url' in profileData ? profileData.cv_url || '' : '';
|
||||
|
||||
setCvResume({
|
||||
cvUrl,
|
||||
resumeUrl: ''
|
||||
});
|
||||
}
|
||||
}, [profileData, profileType, setCvResume]);
|
||||
};
|
||||
@@ -0,0 +1,60 @@
|
||||
import type { MentorUpdateRequestDto, UserUpdateRequestDto } from '@imphnen-frontend-service/service';
|
||||
|
||||
export interface SocialLink {
|
||||
platform: string;
|
||||
placeholder: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface Experience {
|
||||
id: string;
|
||||
company: string;
|
||||
position: string;
|
||||
duration: string;
|
||||
period: string;
|
||||
}
|
||||
|
||||
export interface Education {
|
||||
id: string;
|
||||
institution: string;
|
||||
degree: string;
|
||||
field: string;
|
||||
period: string;
|
||||
}
|
||||
|
||||
export interface Language {
|
||||
name: string;
|
||||
level: string;
|
||||
}
|
||||
|
||||
export interface PersonalInfo {
|
||||
fullname: string;
|
||||
title: string;
|
||||
bio: string;
|
||||
birthdate: string;
|
||||
gender: string;
|
||||
}
|
||||
|
||||
export interface ContactInfo {
|
||||
email: string;
|
||||
phone: string;
|
||||
location: string;
|
||||
}
|
||||
|
||||
export interface CvResume {
|
||||
cvUrl: string;
|
||||
resumeUrl: string;
|
||||
}
|
||||
|
||||
export interface NotificationState {
|
||||
isOpen: boolean;
|
||||
type: 'success' | 'error';
|
||||
title: string;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export interface ProfileFormProps {
|
||||
showNotification: (type: 'success' | 'error', title: string, message?: string) => void;
|
||||
}
|
||||
|
||||
export type ProfileUpdateData = Partial<MentorUpdateRequestDto | UserUpdateRequestDto>;
|
||||
@@ -0,0 +1,251 @@
|
||||
import { FC, useState, useEffect } from 'react';
|
||||
import { NotificationModal } from '../modals';
|
||||
import { ExperiencesSection } from '../sections/experiences-section';
|
||||
import { CvResumeSection } from '../sections/cv-resume-section';
|
||||
import { DescriptionSection } from '../sections/description-section';
|
||||
import { EducationSection } from '../sections/education-section';
|
||||
import type { MentorUpdateRequestDto, UserUpdateRequestDto } from '@imphnen-frontend-service/service';
|
||||
import { useProfile } from '../contexts/profile-context';
|
||||
|
||||
interface Experience {
|
||||
id: string;
|
||||
company: string;
|
||||
position: string;
|
||||
duration: string;
|
||||
period: string;
|
||||
}
|
||||
|
||||
interface Education {
|
||||
id: string;
|
||||
institution: string;
|
||||
degree: string;
|
||||
field: string;
|
||||
period: string;
|
||||
}
|
||||
|
||||
interface ProfileFormProps {
|
||||
showNotification: (type: 'success' | 'error', title: string, message?: string) => void;
|
||||
isViewOnly?: boolean; // Add isViewOnly prop
|
||||
}
|
||||
|
||||
export const ProfileForm: FC<ProfileFormProps> = ({ showNotification, isViewOnly = false }) => {
|
||||
const { profileData, updateProfile, isUpdating } = useProfile();
|
||||
|
||||
const [notification, setNotification] = useState<{
|
||||
isOpen: boolean;
|
||||
type: 'success' | 'error';
|
||||
title: string;
|
||||
message?: string;
|
||||
}>({
|
||||
isOpen: false,
|
||||
type: 'success',
|
||||
title: '',
|
||||
message: ''
|
||||
});
|
||||
|
||||
const [experiences, setExperiences] = useState<Experience[]>([]);
|
||||
const [education, setEducation] = useState<Education[]>([]);
|
||||
|
||||
|
||||
const [personalInfo, setPersonalInfo] = useState({
|
||||
fullname: '',
|
||||
title: '',
|
||||
bio: '',
|
||||
birthdate: '',
|
||||
gender: ''
|
||||
});
|
||||
|
||||
const [cvResume, setCvResume] = useState({
|
||||
cvUrl: '',
|
||||
resumeUrl: ''
|
||||
});
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
if (profileData) {
|
||||
const experiences = 'experience' in profileData ? profileData.experience || [] : [];
|
||||
setExperiences(experiences);
|
||||
}
|
||||
}, [profileData]);
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
if (profileData) {
|
||||
const education = 'education' in profileData ? profileData.education || [] : [];
|
||||
setEducation(education);
|
||||
}
|
||||
}, [profileData]);
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
if (profileData) {
|
||||
const bio = 'bio' in profileData ? profileData.bio || '' : '';
|
||||
|
||||
let fullname = '';
|
||||
if ('fullname' in profileData) {
|
||||
fullname = profileData.fullname || '';
|
||||
} else if ('legal_name' in profileData) {
|
||||
fullname = profileData.legal_name || '';
|
||||
}
|
||||
|
||||
const title = 'current_role' in profileData ? profileData.current_role || '' : '';
|
||||
const birthdate = 'birthdate' in profileData ? profileData.birthdate || '' : '';
|
||||
const gender = 'gender' in profileData ? profileData.gender || '' : '';
|
||||
|
||||
setPersonalInfo({
|
||||
fullname,
|
||||
title,
|
||||
bio,
|
||||
birthdate,
|
||||
gender
|
||||
});
|
||||
}
|
||||
}, [profileData]);
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
if (profileData) {
|
||||
const cvUrl = 'cv_url' in profileData ? profileData.cv_url || '' : '';
|
||||
|
||||
setCvResume({
|
||||
cvUrl,
|
||||
resumeUrl: ''
|
||||
});
|
||||
}
|
||||
}, [profileData]);
|
||||
|
||||
|
||||
|
||||
function isErrorWithResponse(err: unknown): err is { response: { data: { message: string } } } {
|
||||
return (
|
||||
typeof err === 'object' &&
|
||||
err !== null &&
|
||||
|
||||
typeof (err as { response?: { data?: { message?: unknown } } }).response?.data?.message === 'string'
|
||||
);
|
||||
}
|
||||
|
||||
function isErrorWithMessage(err: unknown): err is { message: string } {
|
||||
return (
|
||||
typeof err === 'object' &&
|
||||
err !== null &&
|
||||
'message' in err &&
|
||||
typeof (err as { message?: unknown }).message === 'string'
|
||||
);
|
||||
}
|
||||
|
||||
function tryParseJsonMessage(msg: string): string {
|
||||
const trimmed = msg.trim();
|
||||
if (trimmed.startsWith('{') && trimmed.endsWith('}')) {
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed);
|
||||
if (parsed && typeof parsed.message === 'string') {
|
||||
return parsed.message;
|
||||
}
|
||||
} catch {
|
||||
return msg;
|
||||
}
|
||||
}
|
||||
return msg;
|
||||
}
|
||||
|
||||
function extractApiMessage(err: unknown): string {
|
||||
if (isErrorWithResponse(err)) {
|
||||
return err.response.data.message;
|
||||
}
|
||||
if (isErrorWithMessage(err)) {
|
||||
const msg = err.message || '';
|
||||
return tryParseJsonMessage(msg);
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
const handleProfileUpdate = async (updates: Partial<MentorUpdateRequestDto | UserUpdateRequestDto>) => {
|
||||
if (isViewOnly) { // Prevent updates if in view-only mode
|
||||
showNotification('error', 'Akses Ditolak', 'Anda tidak memiliki izin untuk mengedit profil ini.');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const result = await updateProfile(updates);
|
||||
showNotification('success', 'Perubahan Berhasil Disimpan');
|
||||
return result;
|
||||
} catch (err: unknown) {
|
||||
console.error('Profile update error:', err);
|
||||
const apiMessage = extractApiMessage(err);
|
||||
showNotification('error', 'Gagal menyimpan perubahan', apiMessage || 'Silakan coba lagi');
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{}
|
||||
<DescriptionSection
|
||||
initialDescription={personalInfo.bio}
|
||||
onSave={async (newDescription) => {
|
||||
await handleProfileUpdate({
|
||||
bio: newDescription || null
|
||||
});
|
||||
}}
|
||||
showNotification={showNotification}
|
||||
isLoading={isUpdating}
|
||||
isViewOnly={isViewOnly} // Pass isViewOnly
|
||||
/>
|
||||
{}
|
||||
<CvResumeSection
|
||||
initialFileName={cvResume.cvUrl}
|
||||
fullname={personalInfo.fullname}
|
||||
onSave={async (cvData) => {
|
||||
await handleProfileUpdate({
|
||||
cv_url: cvData.fileUrl || null
|
||||
});
|
||||
}}
|
||||
showNotification={showNotification}
|
||||
isLoading={isUpdating}
|
||||
isViewOnly={isViewOnly} // Pass isViewOnly
|
||||
/>
|
||||
{}
|
||||
<ExperiencesSection
|
||||
initialExperiences={experiences}
|
||||
onSave={async (newExperiences) => {
|
||||
try {
|
||||
await handleProfileUpdate({
|
||||
experience: newExperiences
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Experience update error:', error);
|
||||
}
|
||||
}}
|
||||
showNotification={showNotification}
|
||||
isLoading={isUpdating}
|
||||
isViewOnly={isViewOnly} // Pass isViewOnly
|
||||
/>
|
||||
|
||||
{}
|
||||
<EducationSection
|
||||
initialEducation={education}
|
||||
onSave={async (newEducations) => {
|
||||
try {
|
||||
await handleProfileUpdate({
|
||||
education: newEducations
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Education update error:', error);
|
||||
}
|
||||
}}
|
||||
showNotification={showNotification}
|
||||
isLoading={isUpdating}
|
||||
isViewOnly={isViewOnly} // Pass isViewOnly
|
||||
/>
|
||||
|
||||
<NotificationModal
|
||||
isOpen={notification.isOpen}
|
||||
onClose={() => setNotification(prev => ({ ...prev, isOpen: false }))}
|
||||
type={notification.type}
|
||||
title={notification.title}
|
||||
message={notification.message}
|
||||
header="Profile"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,115 @@
|
||||
import { FC } from 'react';
|
||||
import { Button } from '@imphnen-frontend-service/ui/atoms';
|
||||
import { EditOutlined } from '@ant-design/icons';
|
||||
import { motion } from 'framer-motion';
|
||||
import { useProfile } from '../contexts/profile-context';
|
||||
|
||||
interface ProfileHeaderProps {
|
||||
onEditProfileClick: () => void;
|
||||
isViewOnly?: boolean; // Add isViewOnly prop
|
||||
}
|
||||
|
||||
export const ProfileHeader: FC<ProfileHeaderProps> = ({ onEditProfileClick, isViewOnly = false }) => {
|
||||
const { profileData, profileType } = useProfile();
|
||||
|
||||
|
||||
const avatarSrc = (profileType === 'user' && profileData && 'avatar' in profileData)
|
||||
? profileData.avatar || "/image/testimonial.webp"
|
||||
: "/image/testimonial.webp";
|
||||
|
||||
|
||||
const displayFullname = profileData?.fullname ||
|
||||
(profileType === 'mentor' && profileData && 'legal_name' in profileData
|
||||
? profileData.legal_name
|
||||
: 'User Name');
|
||||
let displayJob = 'Role';
|
||||
if (profileData) {
|
||||
if (profileType === 'mentor' && 'current_role' in profileData) {
|
||||
displayJob = profileData.current_role || 'Mentor';
|
||||
} else if (profileType === 'user' && 'role' in profileData && profileData.role) {
|
||||
displayJob = profileData.role.name || 'User';
|
||||
} else if ('current_role' in profileData) {
|
||||
|
||||
displayJob = profileData.current_role || 'Role';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const joinDate = profileData && 'created_at' in profileData
|
||||
? new Date(profileData.created_at).toLocaleDateString('id-ID', {
|
||||
year: 'numeric',
|
||||
month: 'long'
|
||||
})
|
||||
: 'April 2024';
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className="bg-white rounded-lg p-6 md:p-8 shadow-sm"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.4 }}
|
||||
>
|
||||
<div className="flex flex-col md:flex-row md:items-center gap-6">
|
||||
<div className="relative flex-shrink-0">
|
||||
<div className="w-24 h-24 md:w-32 md:h-32 rounded-full overflow-hidden bg-primary-100 flex items-center justify-center">
|
||||
<img
|
||||
src={avatarSrc}
|
||||
alt="Profile"
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1">
|
||||
<div className="flex flex-col md:flex-row md:items-start md:justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-xl md:text-2xl font-semibold text-neutral-800 mb-2">
|
||||
{displayFullname}
|
||||
</h1>
|
||||
<p className="text-neutral-600 mb-1">{displayJob}</p>
|
||||
{}
|
||||
<p className="text-sm text-neutral-500 mt-2">
|
||||
Bergabung sejak {joinDate}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{!isViewOnly && ( // Conditionally render the button
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="flex items-center gap-2 self-start"
|
||||
onClick={onEditProfileClick}
|
||||
>
|
||||
<EditOutlined className="text-sm" />
|
||||
Edit Profile
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{profileType === 'mentor' && (
|
||||
<div className="flex justify-around md:justify-start md:gap-12 mt-6 pt-6 border-t border-neutral-100">
|
||||
<div className="text-center md:text-left">
|
||||
<p className="text-lg md:text-xl font-semibold text-primary-500">
|
||||
{profileData && 'mentoring_sessions' in profileData ? profileData.mentoring_sessions || 'N/A' : 'N/A'}
|
||||
</p>
|
||||
<p className="text-xs md:text-sm text-neutral-600">Mentoring Sessions</p>
|
||||
</div>
|
||||
<div className="text-center md:text-left">
|
||||
<p className="text-lg md:text-xl font-semibold text-primary-500">
|
||||
{profileData && 'rating' in profileData ? profileData.rating || 'N/A' : 'N/A'}
|
||||
</p>
|
||||
<p className="text-xs md:text-sm text-neutral-600">Rating</p>
|
||||
</div>
|
||||
<div className="text-center md:text-left">
|
||||
<p className="text-lg md:text-xl font-semibold text-primary-500">
|
||||
0
|
||||
</p>
|
||||
<p className="text-xs md:text-sm text-neutral-600">Certificates</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,187 @@
|
||||
import { useState, FC, useEffect } from 'react';
|
||||
import { PersonalInfoSection } from '../sections/personal-info-section';
|
||||
import { SkillsSection } from '../sections/skills-section';
|
||||
import { LanguagesSection } from '../sections/languages-section';
|
||||
import { SocialMediaSection } from '../sections/social-media-section';
|
||||
import { NotificationType } from '../modals/notification-modal';
|
||||
import { useProfile } from '../contexts/profile-context';
|
||||
import type { MentorUpdateRequestDto, UserUpdateRequestDto } from '@imphnen-frontend-service/service';
|
||||
|
||||
interface Language {
|
||||
name: string;
|
||||
level: string;
|
||||
}
|
||||
|
||||
interface SocialLink {
|
||||
platform: string;
|
||||
placeholder: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
interface ProfileInfoProps {
|
||||
showNotification: (type: NotificationType['type'], title: string, message?: string) => void;
|
||||
}
|
||||
|
||||
export const ProfileInfo: FC<ProfileInfoProps> = ({ showNotification }) => {
|
||||
const { profileData, updateProfile, profileType } = useProfile();
|
||||
|
||||
const [contactInfo, setContactInfo] = useState({
|
||||
email: '',
|
||||
phone: '',
|
||||
location: ''
|
||||
});
|
||||
|
||||
const [currentSkills, setCurrentSkills] = useState<string[]>([]);
|
||||
const [currentLanguages, setCurrentLanguages] = useState<Language[]>([]);
|
||||
const [currentSocialLinks, setCurrentSocialLinks] = useState<SocialLink[]>([
|
||||
{ platform: 'LinkedIn', placeholder: 'linkedin.com/in/yourprofile', value: '' },
|
||||
{ platform: 'Github', placeholder: 'github.com/yourusername', value: '' },
|
||||
{ platform: 'Portfolio', placeholder: 'yourportfolio.com', value: '' },
|
||||
{ platform: 'Twitter', placeholder: 'twitter.com/yourusername', value: '' }
|
||||
]);
|
||||
|
||||
|
||||
const extractContactInfo = (profileData: MentorUpdateRequestDto | UserUpdateRequestDto) => {
|
||||
const email = 'email' in profileData && typeof profileData.email === 'string' ? profileData.email || '' : '';
|
||||
let phone = '';
|
||||
if ('phone_number' in profileData) {
|
||||
phone = profileData.phone_number || '';
|
||||
} else if ('phone_for_verification' in profileData) {
|
||||
phone = profileData.phone_for_verification || '';
|
||||
}
|
||||
|
||||
let location = '';
|
||||
if ('location' in profileData) {
|
||||
location = profileData.location || '';
|
||||
} else if ('domicile' in profileData) {
|
||||
location = profileData.domicile || '';
|
||||
}
|
||||
|
||||
return { email, phone, location };
|
||||
};
|
||||
|
||||
const extractSkills = (profileData: MentorUpdateRequestDto | UserUpdateRequestDto) => {
|
||||
if ('skills' in profileData) {
|
||||
return profileData.skills || [];
|
||||
} else if ('expertise' in profileData) {
|
||||
return profileData.expertise || [];
|
||||
}
|
||||
return [];
|
||||
};
|
||||
|
||||
const extractLanguages = (profileData: MentorUpdateRequestDto | UserUpdateRequestDto): Language[] => {
|
||||
return 'languages' in profileData
|
||||
? (profileData.languages || []).map((lang: string) => ({ name: lang, level: 'Intermediate' }))
|
||||
: [];
|
||||
};
|
||||
|
||||
const extractSocialLinks = (profileData: MentorUpdateRequestDto | UserUpdateRequestDto, profileType: string): SocialLink[] => {
|
||||
const linkedinUrl = 'linkedin_url' in profileData ? profileData.linkedin_url || '' : '';
|
||||
const githubUrl = 'github_url' in profileData ? profileData.github_url || '' : '';
|
||||
let portfolioUrl = '';
|
||||
if (profileType === 'mentor' && 'portfolio_url' in profileData) {
|
||||
portfolioUrl = profileData.portfolio_url || '';
|
||||
} else if (profileType === 'user' && 'website_url' in profileData) {
|
||||
portfolioUrl = profileData.website_url || '';
|
||||
}
|
||||
const twitterUrl = 'twitter_url' in profileData ? profileData.twitter_url || '' : '';
|
||||
|
||||
return [
|
||||
{ platform: 'LinkedIn', placeholder: 'linkedin.com/in/yourprofile', value: linkedinUrl },
|
||||
{ platform: 'Github', placeholder: 'github.com/yourusername', value: githubUrl },
|
||||
{ platform: 'Portfolio', placeholder: 'yourportfolio.com', value: portfolioUrl },
|
||||
{ platform: 'Twitter', placeholder: 'twitter.com/yourusername', value: twitterUrl }
|
||||
];
|
||||
};
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
if (profileData) {
|
||||
setContactInfo(extractContactInfo(profileData));
|
||||
setCurrentSkills(extractSkills(profileData));
|
||||
setCurrentLanguages(extractLanguages(profileData));
|
||||
setCurrentSocialLinks(extractSocialLinks(profileData, profileType));
|
||||
}
|
||||
}, [profileData, profileType]);
|
||||
|
||||
|
||||
const handleProfileUpdate = async (updates: Partial<MentorUpdateRequestDto | UserUpdateRequestDto>) => {
|
||||
try {
|
||||
await updateProfile(updates);
|
||||
showNotification('success', 'Perubahan Berhasil Disimpan');
|
||||
} catch (err) {
|
||||
console.error('Profile update error:', err);
|
||||
showNotification('error', 'Gagal menyimpan perubahan', 'Silakan coba lagi');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PersonalInfoSection
|
||||
initialContactInfo={contactInfo}
|
||||
onSave={async (newContactInfo) => {
|
||||
setContactInfo(newContactInfo);
|
||||
await handleProfileUpdate({
|
||||
phone_for_verification: newContactInfo.phone,
|
||||
location: newContactInfo.location
|
||||
});
|
||||
}}
|
||||
showNotification={showNotification}
|
||||
/>
|
||||
|
||||
<SocialMediaSection
|
||||
initialSocialLinks={currentSocialLinks}
|
||||
onSave={async (newSocialLinks) => {
|
||||
setCurrentSocialLinks(newSocialLinks);
|
||||
const linkedIn = newSocialLinks.find(link => link.platform === 'LinkedIn')?.value;
|
||||
const github = newSocialLinks.find(link => link.platform === 'Github')?.value;
|
||||
const portfolio = newSocialLinks.find(link => link.platform === 'Portfolio')?.value;
|
||||
const twitter = newSocialLinks.find(link => link.platform === 'Twitter')?.value;
|
||||
|
||||
const updates: Partial<MentorUpdateRequestDto | UserUpdateRequestDto> = {
|
||||
linkedin_url: linkedIn || undefined,
|
||||
github_url: github || undefined,
|
||||
twitter_url: twitter || undefined,
|
||||
};
|
||||
|
||||
if (profileType === 'mentor') {
|
||||
(updates as MentorUpdateRequestDto).portfolio_url = portfolio || undefined;
|
||||
} else if (profileType === 'user') {
|
||||
(updates as UserUpdateRequestDto).website_url = portfolio || undefined;
|
||||
}
|
||||
|
||||
await handleProfileUpdate(updates);
|
||||
}}
|
||||
showNotification={showNotification}
|
||||
/>
|
||||
|
||||
<SkillsSection
|
||||
initialSkills={currentSkills}
|
||||
onSave={async (newSkills) => {
|
||||
setCurrentSkills(newSkills);
|
||||
const updates: Partial<MentorUpdateRequestDto | UserUpdateRequestDto> = {};
|
||||
|
||||
if (profileType === 'user') {
|
||||
(updates as UserUpdateRequestDto).skills = newSkills;
|
||||
} else if (profileType === 'mentor') {
|
||||
(updates as MentorUpdateRequestDto).expertise = newSkills;
|
||||
}
|
||||
|
||||
await handleProfileUpdate(updates);
|
||||
}}
|
||||
showNotification={showNotification}
|
||||
/>
|
||||
|
||||
<LanguagesSection
|
||||
initialLanguages={currentLanguages}
|
||||
onSave={async (newLanguages) => {
|
||||
setCurrentLanguages(newLanguages);
|
||||
await handleProfileUpdate({
|
||||
languages: newLanguages.map(lang => lang.name)
|
||||
} as MentorUpdateRequestDto | UserUpdateRequestDto);
|
||||
}}
|
||||
showNotification={showNotification}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,190 @@
|
||||
import { useState, FC, useEffect } from 'react';
|
||||
import { PersonalInfoSection } from '../sections/personal-info-section';
|
||||
import { SkillsSection } from '../sections/skills-section';
|
||||
import { LanguagesSection } from '../sections/languages-section';
|
||||
import { SocialMediaSection } from '../sections/social-media-section';
|
||||
import { NotificationType } from '../modals/notification-modal';
|
||||
import { useProfile } from '../contexts/profile-context';
|
||||
import type { MentorUpdateRequestDto, UserUpdateRequestDto } from '@imphnen-frontend-service/service';
|
||||
|
||||
interface Language {
|
||||
name: string;
|
||||
level: string;
|
||||
}
|
||||
|
||||
interface SocialLink {
|
||||
platform: string;
|
||||
placeholder: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
interface ProfileInfoProps {
|
||||
showNotification: (type: NotificationType['type'], title: string, message?: string) => void;
|
||||
}
|
||||
|
||||
export const ProfileInfo: FC<ProfileInfoProps> = ({ showNotification }) => {
|
||||
const { profileData, updateProfile, profileType } = useProfile();
|
||||
|
||||
const [contactInfo, setContactInfo] = useState({
|
||||
email: '',
|
||||
phone: '',
|
||||
location: ''
|
||||
});
|
||||
|
||||
const [currentSkills, setCurrentSkills] = useState<string[]>([]);
|
||||
const [currentLanguages, setCurrentLanguages] = useState<Language[]>([]);
|
||||
const [currentSocialLinks, setCurrentSocialLinks] = useState<SocialLink[]>([
|
||||
{ platform: 'LinkedIn', placeholder: 'linkedin.com/in/yourprofile', value: '' },
|
||||
{ platform: 'Github', placeholder: 'github.com/yourusername', value: '' },
|
||||
{ platform: 'Portfolio', placeholder: 'yourportfolio.com', value: '' },
|
||||
{ platform: 'Twitter', placeholder: 'twitter.com/yourusername', value: '' }
|
||||
]);
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
if (profileData) {
|
||||
const email = 'email' in profileData ? profileData.email || '' : '';
|
||||
let phone = '';
|
||||
if ('phone_number' in profileData) {
|
||||
phone = profileData.phone_number || '';
|
||||
} else if ('phone_for_verification' in profileData) {
|
||||
phone = profileData.phone_for_verification || '';
|
||||
}
|
||||
|
||||
let location = '';
|
||||
if ('location' in profileData) {
|
||||
location = profileData.location || '';
|
||||
} else if ('domicile' in profileData) {
|
||||
location = profileData.domicile || '';
|
||||
}
|
||||
|
||||
setContactInfo({ email, phone, location });
|
||||
}
|
||||
}, [profileData]);
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
if (profileData) {
|
||||
let skills: string[] = [];
|
||||
if ('skills' in profileData) {
|
||||
skills = profileData.skills || [];
|
||||
} else if ('expertise' in profileData) {
|
||||
skills = profileData.expertise || [];
|
||||
}
|
||||
setCurrentSkills(skills);
|
||||
}
|
||||
}, [profileData]);
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
if (profileData) {
|
||||
const languages: Language[] = 'languages' in profileData
|
||||
? (profileData.languages || []).map(lang => ({ name: lang, level: 'Intermediate' }))
|
||||
: [];
|
||||
setCurrentLanguages(languages);
|
||||
}
|
||||
}, [profileData]);
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
if (profileData) {
|
||||
const linkedinUrl = 'linkedin_url' in profileData ? profileData.linkedin_url || '' : '';
|
||||
const githubUrl = 'github_url' in profileData ? profileData.github_url || '' : '';
|
||||
let portfolioUrl = '';
|
||||
if (profileType === 'mentor' && 'portfolio_url' in profileData) {
|
||||
portfolioUrl = profileData.portfolio_url || '';
|
||||
} else if (profileType === 'user' && 'website_url' in profileData) {
|
||||
portfolioUrl = profileData.website_url || '';
|
||||
}
|
||||
const twitterUrl = 'twitter_url' in profileData ? profileData.twitter_url || '' : '';
|
||||
|
||||
setCurrentSocialLinks([
|
||||
{ platform: 'LinkedIn', placeholder: 'linkedin.com/in/yourprofile', value: linkedinUrl },
|
||||
{ platform: 'Github', placeholder: 'github.com/yourusername', value: githubUrl },
|
||||
{ platform: 'Portfolio', placeholder: 'yourportfolio.com', value: portfolioUrl },
|
||||
{ platform: 'Twitter', placeholder: 'twitter.com/yourusername', value: twitterUrl }
|
||||
]);
|
||||
}
|
||||
}, [profileData, profileType]);
|
||||
|
||||
|
||||
const handleProfileUpdate = async (updates: Partial<MentorUpdateRequestDto | UserUpdateRequestDto>) => {
|
||||
try {
|
||||
await updateProfile(updates);
|
||||
showNotification('success', 'Perubahan Berhasil Disimpan');
|
||||
} catch (err) {
|
||||
console.error('Profile update error:', err);
|
||||
showNotification('error', 'Gagal menyimpan perubahan', 'Silakan coba lagi');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PersonalInfoSection
|
||||
initialContactInfo={contactInfo}
|
||||
onSave={async (newContactInfo) => {
|
||||
setContactInfo(newContactInfo);
|
||||
await handleProfileUpdate({
|
||||
phone_for_verification: newContactInfo.phone,
|
||||
location: newContactInfo.location
|
||||
});
|
||||
}}
|
||||
showNotification={showNotification}
|
||||
/>
|
||||
|
||||
<SocialMediaSection
|
||||
initialSocialLinks={currentSocialLinks}
|
||||
onSave={async (newSocialLinks) => {
|
||||
setCurrentSocialLinks(newSocialLinks);
|
||||
const linkedIn = newSocialLinks.find(link => link.platform === 'LinkedIn')?.value;
|
||||
const github = newSocialLinks.find(link => link.platform === 'Github')?.value;
|
||||
const portfolio = newSocialLinks.find(link => link.platform === 'Portfolio')?.value;
|
||||
const twitter = newSocialLinks.find(link => link.platform === 'Twitter')?.value;
|
||||
|
||||
const updates: Partial<MentorUpdateRequestDto | UserUpdateRequestDto> = {
|
||||
linkedin_url: linkedIn || undefined,
|
||||
github_url: github || undefined,
|
||||
twitter_url: twitter || undefined,
|
||||
};
|
||||
|
||||
if (profileType === 'mentor') {
|
||||
(updates as MentorUpdateRequestDto).portfolio_url = portfolio || undefined;
|
||||
} else if (profileType === 'user') {
|
||||
(updates as UserUpdateRequestDto).website_url = portfolio || undefined;
|
||||
}
|
||||
|
||||
await handleProfileUpdate(updates);
|
||||
}}
|
||||
showNotification={showNotification}
|
||||
/>
|
||||
|
||||
<SkillsSection
|
||||
initialSkills={currentSkills}
|
||||
onSave={async (newSkills) => {
|
||||
setCurrentSkills(newSkills);
|
||||
const updates: Partial<MentorUpdateRequestDto | UserUpdateRequestDto> = {};
|
||||
|
||||
if (profileType === 'user') {
|
||||
(updates as UserUpdateRequestDto).skills = newSkills;
|
||||
} else if (profileType === 'mentor') {
|
||||
(updates as MentorUpdateRequestDto).expertise = newSkills;
|
||||
}
|
||||
|
||||
await handleProfileUpdate(updates);
|
||||
}}
|
||||
showNotification={showNotification}
|
||||
/>
|
||||
|
||||
<LanguagesSection
|
||||
initialLanguages={currentLanguages}
|
||||
onSave={async (newLanguages) => {
|
||||
setCurrentLanguages(newLanguages);
|
||||
await handleProfileUpdate({
|
||||
languages: newLanguages.map(lang => lang.name)
|
||||
} as MentorUpdateRequestDto | UserUpdateRequestDto);
|
||||
}}
|
||||
showNotification={showNotification}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,239 @@
|
||||
import { FC, useState, useEffect, useCallback } from 'react';
|
||||
import { Select } from '@imphnen-frontend-service/ui/atoms';
|
||||
import { SectionWrapper } from '../shared/section-wrapper';
|
||||
import { NotificationType } from '../modals/notification-modal';
|
||||
import { PersonalInfoSection } from '../sections/personal-info-section';
|
||||
import { SkillsSection } from '../sections/skills-section';
|
||||
import type { MentorUpdateRequestDto, UserUpdateRequestDto } from '@imphnen-frontend-service/service';
|
||||
import { useProfile } from '../contexts/profile-context';
|
||||
|
||||
interface ProfileSidebarProps {
|
||||
showNotification: (type: NotificationType['type'], title: string, message?: string) => void;
|
||||
isViewOnly?: boolean; // Add isViewOnly prop
|
||||
}
|
||||
|
||||
export const ProfileSidebar: FC<ProfileSidebarProps> = ({ showNotification, isViewOnly = false }) => {
|
||||
const { profileData, updateProfile, profileType, isLoading, isUpdating } = useProfile();
|
||||
|
||||
|
||||
const getCareerStatus = useCallback(() => {
|
||||
if (!profileData) {
|
||||
return 'Career Status';
|
||||
}
|
||||
|
||||
if (profileType === 'user' && 'career_status' in profileData) {
|
||||
const status = profileData.career_status;
|
||||
if (status && typeof status === 'string' && status.trim() !== '') {
|
||||
return status;
|
||||
}
|
||||
}
|
||||
|
||||
if (profileType === 'mentor' && 'availability_commitment' in profileData) {
|
||||
const commitment = profileData.availability_commitment;
|
||||
if (commitment && typeof commitment === 'string' && commitment.trim() !== '') {
|
||||
return commitment;
|
||||
}
|
||||
}
|
||||
|
||||
return 'Career Status';
|
||||
}, [profileType, profileData]);
|
||||
|
||||
const getEmail = useCallback(() => {
|
||||
if (profileData && 'email' in profileData) {
|
||||
return profileData.email || 'email@example.com';
|
||||
}
|
||||
return 'email@example.com';
|
||||
}, [profileData]);
|
||||
|
||||
const getPhone = useCallback(() => {
|
||||
if (profileData && 'phone_for_verification' in profileData && profileData.phone_for_verification) {
|
||||
return profileData.phone_for_verification;
|
||||
}
|
||||
if (profileType === 'user' && profileData && 'phone_number' in profileData && profileData.phone_number) {
|
||||
return profileData.phone_number;
|
||||
}
|
||||
return '+62 (88) 8888 8888';
|
||||
}, [profileType, profileData]);
|
||||
|
||||
const getLocation = useCallback(() => {
|
||||
if (profileData && 'domicile' in profileData && profileData.domicile) {
|
||||
return profileData.domicile;
|
||||
}
|
||||
if (profileType === 'user' && profileData && 'location' in profileData && profileData.location) {
|
||||
return profileData.location;
|
||||
}
|
||||
return 'Location';
|
||||
}, [profileType, profileData]);
|
||||
|
||||
const getSkills = useCallback(() => {
|
||||
if (profileType === 'mentor' && profileData && 'expertise' in profileData && profileData.expertise) {
|
||||
return Array.isArray(profileData.expertise) ? profileData.expertise : [];
|
||||
}
|
||||
if (profileType === 'user' && profileData && 'skills' in profileData && profileData.skills) {
|
||||
return Array.isArray(profileData.skills) ? profileData.skills : [];
|
||||
}
|
||||
return [''];
|
||||
}, [profileType, profileData]);
|
||||
|
||||
const [careerStatus, setCareerStatus] = useState<string>('Career Status');
|
||||
const [isUpdatingCareerStatus, setIsUpdatingCareerStatus] = useState(false);
|
||||
const [isInitialized, setIsInitialized] = useState(false);
|
||||
|
||||
const [personalInfo, setPersonalInfo] = useState({
|
||||
email: 'email@example.com',
|
||||
phone: '+62 (88) 8888 8888',
|
||||
location: 'Location'
|
||||
});
|
||||
|
||||
const [skills, setSkills] = useState<string[]>(['']);
|
||||
|
||||
useEffect(() => {
|
||||
if (profileData && !isLoading && careerStatus === 'Career Status') {
|
||||
const initialCareerStatus = getCareerStatus();
|
||||
|
||||
setCareerStatus(initialCareerStatus);
|
||||
setIsInitialized(true);
|
||||
}
|
||||
}, [profileData, isLoading, careerStatus, getCareerStatus]);
|
||||
|
||||
useEffect(() => {
|
||||
if (profileData && !isLoading) {
|
||||
if (!isUpdatingCareerStatus && isInitialized) {
|
||||
const newCareerStatus = getCareerStatus();
|
||||
console.log('ProfileSidebar: Updating career status from API:', newCareerStatus);
|
||||
setCareerStatus(newCareerStatus);
|
||||
}
|
||||
|
||||
setPersonalInfo({
|
||||
email: getEmail(),
|
||||
phone: getPhone(),
|
||||
location: getLocation()
|
||||
});
|
||||
|
||||
setSkills(getSkills());
|
||||
}
|
||||
}, [profileData, profileType, isLoading, isInitialized, getCareerStatus, getEmail, getPhone, getLocation, getSkills, isUpdatingCareerStatus]);
|
||||
|
||||
|
||||
const tryParseJsonMessage = (msg: string): string => {
|
||||
if (msg.trim().startsWith('{') && msg.trim().endsWith('}')) {
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed);
|
||||
if (parsed && typeof parsed.message === 'string') {
|
||||
return parsed.message;
|
||||
}
|
||||
} catch {
|
||||
return msg;
|
||||
}
|
||||
}
|
||||
return msg;
|
||||
};
|
||||
|
||||
|
||||
const extractApiMessage = (err: unknown): string => {
|
||||
if (typeof err !== 'object' || err === null) return '';
|
||||
|
||||
const maybeAxiosError = err as { response?: { data?: { message?: string } } };
|
||||
if (maybeAxiosError.response?.data?.message) {
|
||||
return maybeAxiosError.response.data.message;
|
||||
}
|
||||
|
||||
if ('message' in err && typeof (err as { message?: string }).message === 'string') {
|
||||
const msg = (err as { message?: string }).message || '';
|
||||
return tryParseJsonMessage(msg);
|
||||
}
|
||||
|
||||
return '';
|
||||
};
|
||||
|
||||
const handleProfileUpdate = async (updates: Partial<MentorUpdateRequestDto | UserUpdateRequestDto>) => {
|
||||
if (isViewOnly) { // Prevent updates if in view-only mode
|
||||
showNotification('error', 'Akses Ditolak', 'Anda tidak memiliki izin untuk mengedit profil ini.');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await updateProfile(updates);
|
||||
showNotification('success', 'Perubahan Berhasil Disimpan');
|
||||
} catch (err) {
|
||||
console.error('Profile update error:', err);
|
||||
const apiMessage = extractApiMessage(err);
|
||||
showNotification('error', 'Gagal menyimpan perubahan', apiMessage || 'Silakan coba lagi');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<SectionWrapper title="Career Status">
|
||||
<Select
|
||||
value={careerStatus}
|
||||
onChange={async (e) => {
|
||||
if (isUpdatingCareerStatus) return; // Prevent multiple clicks
|
||||
const newStatus = e.target.value;
|
||||
console.log('ProfileSidebar: User selected career status:', newStatus);
|
||||
setCareerStatus(newStatus);
|
||||
setIsUpdatingCareerStatus(true);
|
||||
try {
|
||||
console.log('ProfileSidebar: Updating career status on backend...');
|
||||
|
||||
const updates: Partial<MentorUpdateRequestDto | UserUpdateRequestDto> = {};
|
||||
if (profileType === 'mentor') {
|
||||
(updates as MentorUpdateRequestDto).availability_commitment = newStatus;
|
||||
} else {
|
||||
(updates as UserUpdateRequestDto).career_status = newStatus;
|
||||
}
|
||||
|
||||
await handleProfileUpdate(updates);
|
||||
} catch (error) {
|
||||
console.error('ProfileSidebar: Career status update failed:', error);
|
||||
} finally {
|
||||
setIsUpdatingCareerStatus(false);
|
||||
}
|
||||
}}
|
||||
className="w-full min-w-[200px]"
|
||||
disabled={isUpdatingCareerStatus || isViewOnly} // Disable if in view-only mode
|
||||
>
|
||||
<option value="Career Status">Career Status</option>
|
||||
<option value="Student">Student</option>
|
||||
<option value="Fresh Graduate">Fresh Graduate</option>
|
||||
<option value="Junior Developer">Junior Developer</option>
|
||||
<option value="Senior Developer">Senior Developer</option>
|
||||
<option value="Team Lead">Team Lead</option>
|
||||
<option value="Freelancer">Freelancer</option>
|
||||
</Select>
|
||||
</SectionWrapper>
|
||||
|
||||
<PersonalInfoSection
|
||||
initialContactInfo={personalInfo}
|
||||
onSave={async (newPersonalInfo) => {
|
||||
setPersonalInfo(newPersonalInfo);
|
||||
const updates: Partial<MentorUpdateRequestDto | UserUpdateRequestDto> = {
|
||||
phone_for_verification: newPersonalInfo.phone || null,
|
||||
domicile: newPersonalInfo.location || null
|
||||
};
|
||||
await handleProfileUpdate(updates);
|
||||
}}
|
||||
showNotification={showNotification}
|
||||
isLoading={isUpdating}
|
||||
isViewOnly={isViewOnly} // Pass isViewOnly
|
||||
/>
|
||||
|
||||
<SkillsSection
|
||||
initialSkills={skills}
|
||||
onSave={async (newSkills) => {
|
||||
setSkills(newSkills);
|
||||
|
||||
const updates: Partial<MentorUpdateRequestDto | UserUpdateRequestDto> = {};
|
||||
if (profileType === 'mentor') {
|
||||
(updates as MentorUpdateRequestDto).expertise = newSkills;
|
||||
} else if (profileType === 'user') {
|
||||
(updates as UserUpdateRequestDto).skills = newSkills;
|
||||
}
|
||||
await handleProfileUpdate(updates);
|
||||
}}
|
||||
showNotification={showNotification}
|
||||
isLoading={isUpdating}
|
||||
isViewOnly={isViewOnly} // Pass isViewOnly
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,245 @@
|
||||
import { FC, useState } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { BookOutlined, TrophyOutlined, ClockCircleOutlined, SafetyCertificateOutlined } from '@ant-design/icons';
|
||||
import { For } from '@imphnen-frontend-service/utils';
|
||||
|
||||
type TabType = 'overview' | 'certificates' | 'activity' | 'mentoring';
|
||||
|
||||
const tabs = [
|
||||
{ id: 'overview', label: 'Overview', icon: BookOutlined },
|
||||
{ id: 'certificates', label: 'Certificates', icon: SafetyCertificateOutlined },
|
||||
{ id: 'activity', label: 'Activity', icon: ClockCircleOutlined },
|
||||
{ id: 'mentoring', label: 'Mentoring', icon: TrophyOutlined },
|
||||
] as const;
|
||||
|
||||
const certificates = [
|
||||
{
|
||||
id: 1,
|
||||
title: 'UI/UX Design Fundamentals',
|
||||
issuer: 'Google',
|
||||
date: 'March 2024',
|
||||
image: '/image/certificate-placeholder.jpg'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: 'Advanced Figma Techniques',
|
||||
issuer: 'Coursera',
|
||||
date: 'February 2024',
|
||||
image: '/image/certificate-placeholder.jpg'
|
||||
}
|
||||
];
|
||||
|
||||
const activities = [
|
||||
{
|
||||
id: 1,
|
||||
type: 'mentoring',
|
||||
title: 'Completed mentoring session with Riko',
|
||||
date: '2 hours ago',
|
||||
icon: TrophyOutlined
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
type: 'certificate',
|
||||
title: 'Earned UI/UX Design Fundamentals certificate',
|
||||
date: '1 day ago',
|
||||
icon: SafetyCertificateOutlined
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
type: 'mentoring',
|
||||
title: 'Started new mentoring session',
|
||||
date: '3 days ago',
|
||||
icon: TrophyOutlined
|
||||
}
|
||||
];
|
||||
|
||||
const mentoringStats = [
|
||||
{ label: 'Total Sessions', value: '15', change: '+3 this month' },
|
||||
{ label: 'Average Rating', value: '4.8/5', change: '+0.2 from last month' },
|
||||
{ label: 'Total Hours', value: '45h', change: '+12h this month' },
|
||||
{ label: 'Active Mentees', value: '8', change: '+2 this month' }
|
||||
];
|
||||
|
||||
export const ProfileTabs: FC = () => {
|
||||
const [activeTab, setActiveTab] = useState<TabType>('overview');
|
||||
|
||||
const renderTabContent = () => {
|
||||
switch (activeTab) {
|
||||
case 'overview':
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -20 }}
|
||||
className="space-y-6"
|
||||
>
|
||||
<div className="bg-white rounded-lg p-6 shadow-sm">
|
||||
<h3 className="text-lg font-semibold text-neutral-800 mb-4">About Me</h3>
|
||||
<p className="text-neutral-600 leading-relaxed">
|
||||
Passionate UI/UX Designer with 5+ years of experience creating user-centered designs
|
||||
for web and mobile applications. I love mentoring aspiring designers and sharing
|
||||
knowledge about design thinking, prototyping, and user research methodologies.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg p-6 shadow-sm">
|
||||
<h3 className="text-lg font-semibold text-neutral-800 mb-4">Recent Achievements</h3>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="p-4 bg-primary-50 rounded-lg">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<TrophyOutlined className="text-primary-500" />
|
||||
<h4 className="font-medium text-neutral-800">Top Mentor</h4>
|
||||
</div>
|
||||
<p className="text-sm text-neutral-600">Ranked #1 in UI/UX mentoring this month</p>
|
||||
</div>
|
||||
<div className="p-4 bg-green-50 rounded-lg">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<SafetyCertificateOutlined className="text-green-500" />
|
||||
<h4 className="font-medium text-neutral-800">New Certificate</h4>
|
||||
</div>
|
||||
<p className="text-sm text-neutral-600">Google UX Design Professional Certificate</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
|
||||
case 'certificates':
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -20 }}
|
||||
className="space-y-6"
|
||||
>
|
||||
<div className="bg-white rounded-lg p-6 shadow-sm">
|
||||
<h3 className="text-lg font-semibold text-neutral-800 mb-4">My Certificates</h3>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<For data={certificates}>
|
||||
{(cert) => (
|
||||
<div key={cert.id} className="border border-neutral-200 rounded-lg p-4">
|
||||
<div className="w-full h-32 bg-neutral-100 rounded-lg mb-4 flex items-center justify-center">
|
||||
<SafetyCertificateOutlined className="text-4xl text-neutral-400" />
|
||||
</div>
|
||||
<h4 className="font-medium text-neutral-800 mb-1">{cert.title}</h4>
|
||||
<p className="text-sm text-neutral-600">{cert.issuer}</p>
|
||||
<p className="text-xs text-neutral-500 mt-2">{cert.date}</p>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
|
||||
case 'activity':
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -20 }}
|
||||
className="space-y-6"
|
||||
>
|
||||
<div className="bg-white rounded-lg p-6 shadow-sm">
|
||||
<h3 className="text-lg font-semibold text-neutral-800 mb-4">Recent Activity</h3>
|
||||
<div className="space-y-4">
|
||||
<For data={activities}>
|
||||
{(activity) => (
|
||||
<div key={activity.id} className="flex items-start gap-4 p-4 border border-neutral-100 rounded-lg">
|
||||
<div className="w-10 h-10 bg-primary-100 rounded-full flex items-center justify-center">
|
||||
<activity.icon className="text-primary-500" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="font-medium text-neutral-800">{activity.title}</p>
|
||||
<p className="text-sm text-neutral-500">{activity.date}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
|
||||
case 'mentoring':
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -20 }}
|
||||
className="space-y-6"
|
||||
>
|
||||
<div className="bg-white rounded-lg p-6 shadow-sm">
|
||||
<h3 className="text-lg font-semibold text-neutral-800 mb-4">Mentoring Statistics</h3>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<For data={mentoringStats}>
|
||||
{(stat) => (
|
||||
<div key={stat.label} className="p-4 border border-neutral-200 rounded-lg">
|
||||
<h4 className="text-2xl font-bold text-primary-500 mb-1">{stat.value}</h4>
|
||||
<p className="font-medium text-neutral-800 mb-1">{stat.label}</p>
|
||||
<p className="text-sm text-green-600">{stat.change}</p>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg p-6 shadow-sm">
|
||||
<h3 className="text-lg font-semibold text-neutral-800 mb-4">Mentoring Feedback</h3>
|
||||
<div className="space-y-4">
|
||||
<div className="p-4 border border-neutral-100 rounded-lg">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<div className="flex text-yellow-400">
|
||||
<span>★★★★★</span>
|
||||
</div>
|
||||
<span className="text-sm text-neutral-600">5.0</span>
|
||||
</div>
|
||||
<p className="text-neutral-700 mb-2">
|
||||
"Excellent mentor! Very patient and explains concepts clearly."
|
||||
</p>
|
||||
<p className="text-sm text-neutral-500">- Riko, Junior Developer</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow-sm">
|
||||
<div className="border-b border-neutral-200">
|
||||
<nav className="flex space-x-8 px-6 py-4 overflow-x-auto">
|
||||
<For data={tabs}>
|
||||
{(tab) => {
|
||||
const Icon = tab.icon;
|
||||
return (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id as TabType)}
|
||||
className={`flex items-center gap-2 px-3 py-2 text-sm font-medium whitespace-nowrap border-b-2 transition-colors ${
|
||||
activeTab === tab.id
|
||||
? 'border-primary-500 text-primary-600'
|
||||
: 'border-transparent text-neutral-600 hover:text-neutral-800 hover:border-neutral-300'
|
||||
}`}
|
||||
>
|
||||
<Icon className="text-sm" />
|
||||
{tab.label}
|
||||
</button>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<div className="p-6">
|
||||
<AnimatePresence mode="wait">
|
||||
{renderTabContent()}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
+95
@@ -0,0 +1,95 @@
|
||||
import { FC, useState, useEffect } from 'react';
|
||||
import { MailOutlined, PhoneOutlined, EnvironmentOutlined } from '@ant-design/icons';
|
||||
import { PersonalInfoModal } from '../modals';
|
||||
import { SectionWrapper } from '../shared/section-wrapper';
|
||||
import { NotificationType } from '../modals/notification-modal';
|
||||
import { EditSectionButton } from '../buttons/edit-section-button';
|
||||
|
||||
interface ContactInfo {
|
||||
email: string;
|
||||
phone: string;
|
||||
location: string;
|
||||
}
|
||||
|
||||
interface ContactInfoSectionProps {
|
||||
initialContactInfo: ContactInfo;
|
||||
onSave: (newContactInfo: ContactInfo) => Promise<void>;
|
||||
showNotification: (type: NotificationType['type'], title: string, message?: string) => void;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
export const ContactInfoSection: FC<ContactInfoSectionProps> = ({
|
||||
initialContactInfo,
|
||||
onSave,
|
||||
showNotification,
|
||||
isLoading,
|
||||
}) => {
|
||||
const [isPersonalInfoModalOpen, setIsPersonalInfoModalOpen] = useState(false);
|
||||
const [contactInfo, setContactInfo] = useState<ContactInfo>(initialContactInfo);
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
setContactInfo(initialContactInfo);
|
||||
}, [initialContactInfo]);
|
||||
|
||||
const handleSave = async (newInfo: { email: string; phone: string; location: string }) => {
|
||||
|
||||
const newContactInfo = { email: newInfo.email, phone: newInfo.phone, location: newInfo.location };
|
||||
|
||||
|
||||
|
||||
await onSave(newContactInfo);
|
||||
};
|
||||
|
||||
return (
|
||||
<SectionWrapper
|
||||
title="Personal Informations"
|
||||
editButton={
|
||||
<EditSectionButton onClick={() => setIsPersonalInfoModalOpen(true)} />
|
||||
}
|
||||
delay={0.1}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-8 h-8 bg-[#23A1EB]/10 rounded-lg flex items-center justify-center mt-0.5">
|
||||
<MailOutlined className="text-[#23A1EB] text-sm" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium text-gray-900">{contactInfo.email}</p>
|
||||
<p className="text-sm text-gray-600">Email Address</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-8 h-8 bg-[#23A1EB]/10 rounded-lg flex items-center justify-center mt-0.5">
|
||||
<PhoneOutlined className="text-[#23A1EB] text-sm" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium text-gray-900">{contactInfo.phone}</p>
|
||||
<p className="text-sm text-gray-600">Phone Number</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-8 h-8 bg-[#23A1EB]/10 rounded-lg flex items-center justify-center mt-0.5">
|
||||
<EnvironmentOutlined className="text-[#23A1EB] text-sm" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium text-gray-900">{contactInfo.location}</p>
|
||||
<p className="text-sm text-gray-600">Location</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<PersonalInfoModal
|
||||
isOpen={isPersonalInfoModalOpen}
|
||||
onClose={() => setIsPersonalInfoModalOpen(false)}
|
||||
initialValue={contactInfo}
|
||||
onSave={handleSave}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
</SectionWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import { FC, useState, useEffect } from 'react';
|
||||
import { DownloadOutlined } from '@ant-design/icons';
|
||||
import { Button } from '@imphnen-frontend-service/ui/atoms';
|
||||
import { CVModal } from '../modals';
|
||||
import { SectionWrapper } from '../shared/section-wrapper';
|
||||
import { NotificationType } from '../modals/notification-modal';
|
||||
import { EditSectionButton } from '../buttons/edit-section-button';
|
||||
|
||||
|
||||
interface CvResumeSectionProps {
|
||||
initialFileName: string;
|
||||
fullname: string;
|
||||
onSave: (cvData: { fileName: string; fileUrl?: string }) => Promise<void>;
|
||||
showNotification: (type: NotificationType['type'], title: string, message?: string) => void;
|
||||
isLoading?: boolean;
|
||||
isViewOnly?: boolean; // Add isViewOnly prop
|
||||
}
|
||||
|
||||
export const CvResumeSection: FC<CvResumeSectionProps> = ({
|
||||
initialFileName,
|
||||
fullname,
|
||||
onSave,
|
||||
showNotification,
|
||||
isLoading = false,
|
||||
isViewOnly = false, // Default to false
|
||||
}) => {
|
||||
const [isCVModalOpen, setIsCVModalOpen] = useState(false);
|
||||
const [fileName, setFileName] = useState(initialFileName);
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
setFileName(initialFileName);
|
||||
}, [initialFileName]);
|
||||
|
||||
const handleSave = async (cvData: { fileName: string; fileUrl?: string }) => {
|
||||
if (isViewOnly) return; // Prevent save if in view-only mode
|
||||
await onSave(cvData);
|
||||
};
|
||||
|
||||
|
||||
let displayFileName = 'Belum ada CV';
|
||||
let fileUrl = '';
|
||||
if (fileName) {
|
||||
if (fileName.startsWith('http') && fullname) {
|
||||
displayFileName = `${fullname}.pdf`;
|
||||
fileUrl = fileName;
|
||||
} else {
|
||||
displayFileName = fileName;
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
const handleDownload = () => {
|
||||
if (fileUrl) {
|
||||
const link = document.createElement('a');
|
||||
link.href = fileUrl;
|
||||
link.download = displayFileName;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<SectionWrapper
|
||||
title="CV/Resume"
|
||||
editButton={
|
||||
!isViewOnly ? ( // Conditionally render the edit button
|
||||
<EditSectionButton
|
||||
onClick={() => setIsCVModalOpen(true)}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
delay={0.3}
|
||||
>
|
||||
<div className="flex items-center gap-4 p-4 bg-gray-50 rounded-lg">
|
||||
<div className="w-10 h-10 bg-gray-300 rounded flex items-center justify-center">
|
||||
<span className="text-gray-600 text-xs font-medium">PDF</span>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="font-medium text-gray-900">{displayFileName}</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
className="flex items-center gap-2"
|
||||
disabled={!fileUrl}
|
||||
onClick={handleDownload}
|
||||
>
|
||||
<DownloadOutlined />
|
||||
Download
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<CVModal
|
||||
isOpen={isCVModalOpen && !isViewOnly} // Only open if not in view-only mode
|
||||
onClose={() => setIsCVModalOpen(false)}
|
||||
initialValue={{ fileName, fileUrl: fileName }}
|
||||
onSave={handleSave}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
</SectionWrapper>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,61 @@
|
||||
import { FC, useState, useEffect } from 'react';
|
||||
import { DescriptionModal } from '../modals';
|
||||
import { SectionWrapper } from '../shared/section-wrapper';
|
||||
import { NotificationType } from '../modals/notification-modal';
|
||||
import { EditSectionButton } from '../buttons/edit-section-button';
|
||||
|
||||
interface DescriptionSectionProps {
|
||||
initialDescription: string;
|
||||
onSave: (newDescription: string) => Promise<void>;
|
||||
showNotification: (type: NotificationType['type'], title: string, message?: string) => void;
|
||||
isLoading?: boolean;
|
||||
isViewOnly?: boolean; // Add isViewOnly prop
|
||||
}
|
||||
|
||||
export const DescriptionSection: FC<DescriptionSectionProps> = ({
|
||||
initialDescription,
|
||||
onSave,
|
||||
showNotification,
|
||||
isLoading = false,
|
||||
isViewOnly = false, // Default to false
|
||||
}) => {
|
||||
const [isDescriptionModalOpen, setIsDescriptionModalOpen] = useState(false);
|
||||
const [description, setDescription] = useState(initialDescription);
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
setDescription(initialDescription);
|
||||
}, [initialDescription]);
|
||||
|
||||
const handleSave = async (newDescription: string) => {
|
||||
if (isViewOnly) return; // Prevent save if in view-only mode
|
||||
await onSave(newDescription);
|
||||
};
|
||||
|
||||
return (
|
||||
<SectionWrapper
|
||||
title="Description"
|
||||
editButton={
|
||||
!isViewOnly ? ( // Conditionally render the edit button
|
||||
<EditSectionButton
|
||||
onClick={() => setIsDescriptionModalOpen(true)}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
delay={0.2}
|
||||
>
|
||||
<div className="text-gray-700 leading-relaxed whitespace-pre-wrap min-h-[150px] p-4 border border-gray-200 rounded-md bg-gray-50">
|
||||
{description}
|
||||
</div>
|
||||
|
||||
<DescriptionModal
|
||||
isOpen={isDescriptionModalOpen && !isViewOnly} // Only open if not in view-only mode
|
||||
onClose={() => setIsDescriptionModalOpen(false)}
|
||||
initialValue={description}
|
||||
onSave={handleSave}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
</SectionWrapper>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,85 @@
|
||||
import { FC, useState, useEffect } from 'react';
|
||||
import { EducationModal } from '../modals';
|
||||
import { SectionWrapper } from '../shared/section-wrapper';
|
||||
import { NotificationType } from '../modals/notification-modal';
|
||||
import { EditSectionButton } from '../buttons/edit-section-button';
|
||||
|
||||
interface Education {
|
||||
id: string;
|
||||
institution: string;
|
||||
degree: string;
|
||||
field: string;
|
||||
period: string;
|
||||
}
|
||||
|
||||
interface EducationSectionProps {
|
||||
initialEducation: Education[];
|
||||
onSave: (newEducation: Education[]) => Promise<void>;
|
||||
showNotification: (type: NotificationType['type'], title: string, message?: string) => void;
|
||||
isLoading?: boolean;
|
||||
isViewOnly?: boolean; // Add isViewOnly prop
|
||||
}
|
||||
|
||||
export const EducationSection: FC<EducationSectionProps> = ({
|
||||
initialEducation,
|
||||
onSave,
|
||||
showNotification,
|
||||
isLoading = false,
|
||||
isViewOnly = false, // Default to false
|
||||
}) => {
|
||||
const [isEducationModalOpen, setIsEducationModalOpen] = useState(false);
|
||||
const [education, setEducation] = useState<Education[]>(initialEducation);
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
setEducation(initialEducation);
|
||||
}, [initialEducation]);
|
||||
|
||||
const handleSave = async (newEducation: Education[]) => {
|
||||
if (isViewOnly) return; // Prevent save if in view-only mode
|
||||
await onSave(newEducation);
|
||||
};
|
||||
|
||||
return (
|
||||
<SectionWrapper
|
||||
title="Education"
|
||||
editButton={
|
||||
!isViewOnly ? ( // Conditionally render the edit button
|
||||
<div className="flex gap-2">
|
||||
<EditSectionButton
|
||||
onClick={() => setIsEducationModalOpen(true)}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
) : null
|
||||
}
|
||||
delay={0.5}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
{education.map((edu) => (
|
||||
<div key={edu.id} className="flex items-start gap-4 p-4 border border-gray-200 rounded-lg">
|
||||
<div className="w-10 h-10 bg-gray-200 rounded-full flex-shrink-0"></div>
|
||||
<div className="flex-1">
|
||||
<h4 className="font-semibold text-gray-900">{edu.institution}</h4>
|
||||
<p className="text-gray-700">{edu.degree}</p>
|
||||
<div className="flex items-center gap-4 mt-2 text-sm text-gray-500">
|
||||
<span>{edu.field}</span>
|
||||
<span>•</span>
|
||||
<span>{edu.period}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<EducationModal
|
||||
isOpen={isEducationModalOpen && !isViewOnly} // Only open if not in view-only mode
|
||||
onClose={() => setIsEducationModalOpen(false)}
|
||||
initialValue={education}
|
||||
onSave={handleSave}
|
||||
isLoading={isLoading}
|
||||
showNotification={showNotification}
|
||||
/>
|
||||
</SectionWrapper>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,85 @@
|
||||
import { FC, useState, useEffect } from 'react';
|
||||
import { ExperienceModal } from '../modals';
|
||||
import { SectionWrapper } from '../shared/section-wrapper';
|
||||
import { NotificationType } from '../modals/notification-modal';
|
||||
import { EditSectionButton } from '../buttons/edit-section-button';
|
||||
|
||||
interface Experience {
|
||||
id: string;
|
||||
company: string;
|
||||
position: string;
|
||||
duration: string;
|
||||
period: string;
|
||||
}
|
||||
|
||||
interface ExperiencesSectionProps {
|
||||
initialExperiences: Experience[];
|
||||
onSave: (newExperiences: Experience[]) => Promise<void>;
|
||||
showNotification: (type: NotificationType['type'], title: string, message?: string) => void;
|
||||
isLoading?: boolean;
|
||||
isViewOnly?: boolean; // Add isViewOnly prop
|
||||
}
|
||||
|
||||
export const ExperiencesSection: FC<ExperiencesSectionProps> = ({
|
||||
initialExperiences,
|
||||
onSave,
|
||||
showNotification,
|
||||
isLoading = false,
|
||||
isViewOnly = false, // Default to false
|
||||
}) => {
|
||||
const [isExperienceModalOpen, setIsExperienceModalOpen] = useState(false);
|
||||
const [experiences, setExperiences] = useState<Experience[]>(initialExperiences);
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
setExperiences(initialExperiences);
|
||||
}, [initialExperiences]);
|
||||
|
||||
const handleSave = async (newExperiences: Experience[]) => {
|
||||
if (isViewOnly) return; // Prevent save if in view-only mode
|
||||
await onSave(newExperiences);
|
||||
};
|
||||
|
||||
return (
|
||||
<SectionWrapper
|
||||
title="Experiences"
|
||||
editButton={
|
||||
!isViewOnly ? ( // Conditionally render the edit button
|
||||
<div className="flex gap-2">
|
||||
<EditSectionButton
|
||||
onClick={() => setIsExperienceModalOpen(true)}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
) : null
|
||||
}
|
||||
delay={0.4}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
{experiences.map((exp) => (
|
||||
<div key={exp.id} className="flex items-start gap-4 p-4 border border-gray-200 rounded-lg">
|
||||
<div className="w-10 h-10 bg-gray-200 rounded-full flex-shrink-0"></div>
|
||||
<div className="flex-1">
|
||||
<h4 className="font-semibold text-gray-900">{exp.company}</h4>
|
||||
<p className="text-gray-700">{exp.position}</p>
|
||||
<div className="flex items-center gap-4 mt-2 text-sm text-gray-500">
|
||||
<span>{exp.duration}</span>
|
||||
<span>•</span>
|
||||
<span>{exp.period}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<ExperienceModal
|
||||
isOpen={isExperienceModalOpen && !isViewOnly} // Only open if not in view-only mode
|
||||
onClose={() => setIsExperienceModalOpen(false)}
|
||||
initialValue={experiences}
|
||||
onSave={handleSave}
|
||||
isLoading={isLoading}
|
||||
showNotification={showNotification}
|
||||
/>
|
||||
</SectionWrapper>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,8 @@
|
||||
export { SocialMediaSection } from './social-media-section';
|
||||
export { DescriptionSection } from './description-section';
|
||||
export { CvResumeSection } from './cv-resume-section';
|
||||
export { ExperiencesSection } from './experiences-section';
|
||||
export { EducationSection } from './education-section';
|
||||
export { PersonalInfoSection } from './personal-info-section';
|
||||
export { SkillsSection } from './skills-section';
|
||||
export { LanguagesSection } from './languages-section';
|
||||
@@ -0,0 +1,72 @@
|
||||
import { FC, useState, useEffect } from 'react';
|
||||
import { LanguagesModal } from '../modals';
|
||||
import { SectionWrapper } from '../shared/section-wrapper';
|
||||
import { NotificationType } from '../modals/notification-modal';
|
||||
import { EditSectionButton } from '../buttons/edit-section-button';
|
||||
|
||||
interface Language {
|
||||
name: string;
|
||||
level: string;
|
||||
}
|
||||
|
||||
interface LanguagesSectionProps {
|
||||
initialLanguages: Language[];
|
||||
onSave: (newLanguages: Language[]) => void;
|
||||
showNotification: (type: NotificationType['type'], title: string, message?: string) => void;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
export const LanguagesSection: FC<LanguagesSectionProps> = ({
|
||||
initialLanguages,
|
||||
onSave,
|
||||
showNotification,
|
||||
isLoading = false,
|
||||
}) => {
|
||||
const [isLanguagesModalOpen, setIsLanguagesModalOpen] = useState(false);
|
||||
const [languages, setLanguages] = useState<Language[]>(initialLanguages);
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
setLanguages(initialLanguages);
|
||||
}, [initialLanguages]);
|
||||
|
||||
const handleSave = (newLanguages: Language[]) => {
|
||||
|
||||
|
||||
onSave(newLanguages);
|
||||
};
|
||||
|
||||
return (
|
||||
<SectionWrapper
|
||||
title="Languages"
|
||||
editButton={
|
||||
<EditSectionButton
|
||||
onClick={() => setIsLanguagesModalOpen(true)}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
}
|
||||
delay={0.4}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
{languages.map((language) => (
|
||||
<div key={language.name} className="flex justify-between items-center">
|
||||
<span className="text-sm font-medium text-neutral-800">{language.name}</span>
|
||||
<span className="text-xs text-neutral-600 bg-neutral-100 px-2 py-1 rounded">
|
||||
{language.level}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<LanguagesModal
|
||||
isOpen={isLanguagesModalOpen}
|
||||
onClose={() => setIsLanguagesModalOpen(false)}
|
||||
initialValue={languages}
|
||||
onSave={handleSave}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
</SectionWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
+98
@@ -0,0 +1,98 @@
|
||||
import { FC, useState, useEffect } from 'react';
|
||||
import { MailOutlined, PhoneOutlined, EnvironmentOutlined } from '@ant-design/icons';
|
||||
import { PersonalInfoModal } from '../modals';
|
||||
import { SectionWrapper } from '../shared/section-wrapper';
|
||||
import { NotificationType } from '../modals/notification-modal';
|
||||
import { EditSectionButton } from '../buttons/edit-section-button';
|
||||
|
||||
interface PersonalInfo {
|
||||
email: string;
|
||||
phone: string;
|
||||
location: string;
|
||||
}
|
||||
|
||||
interface PersonalInfoSectionProps {
|
||||
initialContactInfo: PersonalInfo;
|
||||
onSave: (newContactInfo: PersonalInfo) => Promise<void>;
|
||||
showNotification: (type: NotificationType['type'], title: string, message?: string) => void;
|
||||
isLoading?: boolean;
|
||||
isViewOnly?: boolean; // Add isViewOnly prop
|
||||
}
|
||||
|
||||
export const PersonalInfoSection: FC<PersonalInfoSectionProps> = ({
|
||||
initialContactInfo,
|
||||
onSave,
|
||||
showNotification,
|
||||
isLoading = false,
|
||||
isViewOnly = false, // Default to false
|
||||
}) => {
|
||||
const [isPersonalInfoModalOpen, setIsPersonalInfoModalOpen] = useState(false);
|
||||
const [personalInfo, setPersonalInfo] = useState<PersonalInfo>(initialContactInfo);
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
setPersonalInfo(initialContactInfo);
|
||||
}, [initialContactInfo]);
|
||||
|
||||
const handleSave = async (newInfo: { email: string; phone: string; location: string }) => {
|
||||
if (isViewOnly) return; // Prevent save if in view-only mode
|
||||
const newPersonalInfo = { email: newInfo.email, phone: newInfo.phone, location: newInfo.location };
|
||||
|
||||
await onSave(newPersonalInfo);
|
||||
};
|
||||
|
||||
return (
|
||||
<SectionWrapper
|
||||
title="Personal Informations"
|
||||
editButton={
|
||||
!isViewOnly ? ( // Conditionally render the edit button
|
||||
<EditSectionButton
|
||||
onClick={() => setIsPersonalInfoModalOpen(true)}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
delay={0.1}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-8 h-8 bg-[#23A1EB]/10 rounded-lg flex items-center justify-center mt-0.5">
|
||||
<MailOutlined className="text-[#23A1EB] text-sm" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium text-gray-900">{personalInfo.email}</p>
|
||||
<p className="text-sm text-gray-600">Email Address</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-8 h-8 bg-[#23A1EB]/10 rounded-lg flex items-center justify-center mt-0.5">
|
||||
<PhoneOutlined className="text-[#23A1EB] text-sm" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium text-gray-900">{personalInfo.phone}</p>
|
||||
<p className="text-sm text-gray-600">Phone Number</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-8 h-8 bg-[#23A1EB]/10 rounded-lg flex items-center justify-center mt-0.5">
|
||||
<EnvironmentOutlined className="text-[#23A1EB] text-sm" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium text-gray-900">{personalInfo.location}</p>
|
||||
<p className="text-sm text-gray-600">Location</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<PersonalInfoModal
|
||||
isOpen={isPersonalInfoModalOpen && !isViewOnly} // Only open if not in view-only mode
|
||||
onClose={() => setIsPersonalInfoModalOpen(false)}
|
||||
initialValue={personalInfo}
|
||||
onSave={handleSave}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
</SectionWrapper>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,75 @@
|
||||
import { FC, useState, useEffect } from 'react';
|
||||
import { SkillsModal } from '../modals';
|
||||
import { SectionWrapper } from '../shared/section-wrapper';
|
||||
import { NotificationType } from '../modals/notification-modal';
|
||||
import { EditSectionButton } from '../buttons/edit-section-button';
|
||||
|
||||
interface Skill {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface SkillsSectionProps {
|
||||
initialSkills: string[];
|
||||
onSave: (newSkills: string[]) => Promise<void>;
|
||||
showNotification: (type: NotificationType['type'], title: string, message?: string) => void;
|
||||
isLoading?: boolean;
|
||||
isViewOnly?: boolean; // Add isViewOnly prop
|
||||
}
|
||||
|
||||
export const SkillsSection: FC<SkillsSectionProps> = ({
|
||||
initialSkills,
|
||||
onSave,
|
||||
showNotification,
|
||||
isLoading = false,
|
||||
isViewOnly = false, // Default to false
|
||||
}) => {
|
||||
const [isSkillsModalOpen, setIsSkillsModalOpen] = useState(false);
|
||||
const [skills, setSkills] = useState<string[]>(initialSkills);
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
setSkills(initialSkills);
|
||||
}, [initialSkills]);
|
||||
|
||||
const handleSave = async (newSkills: Skill[]) => {
|
||||
if (isViewOnly) return; // Prevent save if in view-only mode
|
||||
const stringSkills = newSkills.map(skill => skill.name);
|
||||
|
||||
await onSave(stringSkills);
|
||||
};
|
||||
|
||||
return (
|
||||
<SectionWrapper
|
||||
title="Skills"
|
||||
editButton={
|
||||
!isViewOnly ? ( // Conditionally render the edit button
|
||||
<EditSectionButton
|
||||
onClick={() => setIsSkillsModalOpen(true)}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
delay={0.3}
|
||||
>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{skills.map((skill) => (
|
||||
<span
|
||||
key={skill}
|
||||
className="inline-block px-3 py-1 bg-[#23A1EB]/10 text-[#23A1EB] text-sm rounded-md border border-[#23A1EB]/20"
|
||||
>
|
||||
{skill}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<SkillsModal
|
||||
isOpen={isSkillsModalOpen && !isViewOnly} // Only open if not in view-only mode
|
||||
onClose={() => setIsSkillsModalOpen(false)}
|
||||
initialValue={skills.map(skill => ({ id: skill, name: skill }))}
|
||||
onSave={handleSave}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
</SectionWrapper>
|
||||
);
|
||||
};
|
||||
+69
@@ -0,0 +1,69 @@
|
||||
import { FC, useState, useEffect } from 'react';
|
||||
import { SocialMediaModal } from '../modals';
|
||||
import { SectionWrapper } from '../shared/section-wrapper';
|
||||
import { NotificationType } from '../modals/notification-modal';
|
||||
import { EditSectionButton } from '../buttons/edit-section-button';
|
||||
|
||||
interface SocialLink {
|
||||
platform: string;
|
||||
placeholder: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
interface SocialMediaSectionProps {
|
||||
initialSocialLinks: SocialLink[];
|
||||
onSave: (newSocialLinks: SocialLink[]) => Promise<void>;
|
||||
showNotification: (type: NotificationType['type'], title: string, message?: string) => void;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
export const SocialMediaSection: FC<SocialMediaSectionProps> = ({
|
||||
initialSocialLinks,
|
||||
onSave,
|
||||
showNotification,
|
||||
isLoading,
|
||||
}) => {
|
||||
const [isSocialMediaModalOpen, setIsSocialMediaModalOpen] = useState(false);
|
||||
const [socialLinks, setSocialLinks] = useState<SocialLink[]>(initialSocialLinks);
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
setSocialLinks(initialSocialLinks);
|
||||
}, [initialSocialLinks]);
|
||||
|
||||
const handleSave = async (newSocialLinks: SocialLink[]) => {
|
||||
|
||||
await onSave(newSocialLinks);
|
||||
};
|
||||
|
||||
return (
|
||||
<SectionWrapper
|
||||
title="Social Media"
|
||||
editButton={
|
||||
<EditSectionButton onClick={() => setIsSocialMediaModalOpen(true)} />
|
||||
}
|
||||
delay={0.1}
|
||||
>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{socialLinks.map((link) => (
|
||||
<div key={link.platform} className="border border-gray-200 rounded-md p-4 bg-gray-50">
|
||||
<div className="text-sm font-medium text-gray-700 mb-2">{link.platform}</div>
|
||||
<div className="text-gray-900 text-sm">
|
||||
{link.value || <span className="text-gray-400 italic">{link.placeholder}</span>}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<SocialMediaModal
|
||||
isOpen={isSocialMediaModalOpen}
|
||||
onClose={() => setIsSocialMediaModalOpen(false)}
|
||||
initialValue={socialLinks}
|
||||
onSave={handleSave}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
</SectionWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
import { FC, useRef, useState } from 'react';
|
||||
import { Button } from '@imphnen-frontend-service/ui/atoms';
|
||||
import { UploadOutlined, LoadingOutlined } from '@ant-design/icons';
|
||||
|
||||
interface FileUploaderProps {
|
||||
accept?: string;
|
||||
maxSize?: number;
|
||||
onFileSelect?: (file: File) => void;
|
||||
isLoading?: boolean;
|
||||
className?: string;
|
||||
children?: React.ReactNode;
|
||||
dragAndDrop?: boolean;
|
||||
buttonText?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export const FileUploader: FC<FileUploaderProps> = ({
|
||||
accept = "*/*",
|
||||
maxSize = 10 * 1024 * 1024,
|
||||
onFileSelect,
|
||||
isLoading = false,
|
||||
className = "",
|
||||
children,
|
||||
dragAndDrop = false,
|
||||
buttonText = "Choose File",
|
||||
description = "Click to select a file"
|
||||
}) => {
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
|
||||
const validateFile = (file: File): string | null => {
|
||||
if (file.size > maxSize) {
|
||||
return `File size must be less than ${Math.round(maxSize / (1024 * 1024))}MB`;
|
||||
}
|
||||
|
||||
if (accept !== "*/*" && !accept.split(',').some(type => {
|
||||
const trimmedType = type.trim();
|
||||
if (trimmedType.startsWith('.')) {
|
||||
return file.name.toLowerCase().endsWith(trimmedType.toLowerCase());
|
||||
}
|
||||
return new RegExp(trimmedType.replace('*', '.*')).exec(file.type);
|
||||
})) {
|
||||
return `File type not supported. Accepted types: ${accept}`;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const handleFileSelect = (file: File) => {
|
||||
const error = validateFile(file);
|
||||
if (error) {
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
onFileSelect?.(file);
|
||||
};
|
||||
|
||||
const handleInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (file) {
|
||||
handleFileSelect(file);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragOver = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setIsDragging(true);
|
||||
};
|
||||
|
||||
const handleDragLeave = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setIsDragging(false);
|
||||
};
|
||||
|
||||
const handleDrop = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setIsDragging(false);
|
||||
|
||||
const file = e.dataTransfer.files[0];
|
||||
if (file) {
|
||||
handleFileSelect(file);
|
||||
}
|
||||
};
|
||||
|
||||
const triggerFileSelect = () => {
|
||||
if (!isLoading) {
|
||||
fileInputRef.current?.click();
|
||||
}
|
||||
};
|
||||
|
||||
if (children) {
|
||||
return (
|
||||
<div className={`relative ${className}`}>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept={accept}
|
||||
onChange={handleInputChange}
|
||||
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer z-10"
|
||||
disabled={isLoading}
|
||||
title="Select file to upload"
|
||||
/>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (dragAndDrop) {
|
||||
return (
|
||||
<div className={className}>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept={accept}
|
||||
onChange={handleInputChange}
|
||||
className="hidden"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className={`w-full border-2 border-dashed rounded-lg p-6 text-center transition-colors ${
|
||||
isDragging
|
||||
? 'border-blue-500 bg-blue-50'
|
||||
: 'border-gray-300 hover:border-blue-400 hover:bg-gray-50'
|
||||
} ${isLoading ? 'opacity-50 cursor-not-allowed' : ''}`}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
onClick={triggerFileSelect}
|
||||
disabled={isLoading}
|
||||
aria-label="Upload file by clicking or drag and drop"
|
||||
>
|
||||
{isLoading ? (
|
||||
<div className="flex flex-col items-center">
|
||||
<LoadingOutlined className="text-2xl text-blue-500 mb-2" />
|
||||
<p className="text-sm text-gray-600">Uploading...</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<UploadOutlined className="text-2xl text-gray-400 mb-2" />
|
||||
<p className="text-sm text-gray-600 mb-1">{description}</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
{accept === "*/*" ? "Any file type" : accept} • Max {Math.round(maxSize / (1024 * 1024))}MB
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept={accept}
|
||||
onChange={handleInputChange}
|
||||
className="hidden"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={triggerFileSelect}
|
||||
disabled={isLoading}
|
||||
className="w-full"
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<LoadingOutlined className="mr-2" />
|
||||
Uploading...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<UploadOutlined className="mr-2" />
|
||||
{buttonText}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export { SectionWrapper } from './section-wrapper';
|
||||
@@ -0,0 +1,26 @@
|
||||
import { FC, ReactElement, ReactNode } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
|
||||
interface SectionWrapperProps {
|
||||
title: string;
|
||||
editButton?: ReactElement;
|
||||
children: ReactNode;
|
||||
delay?: number;
|
||||
}
|
||||
|
||||
export const SectionWrapper: FC<SectionWrapperProps> = ({ title, editButton, children, delay = 0 }): ReactElement => {
|
||||
return (
|
||||
<motion.div
|
||||
className="bg-white rounded-lg p-6 shadow-lg hover:shadow-xl transition-shadow duration-300"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3, delay }}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900">{title}</h3>
|
||||
{editButton}
|
||||
</div>
|
||||
{children}
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,132 @@
|
||||
'use client';
|
||||
|
||||
import { FC, ReactElement, useState } from 'react';
|
||||
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';
|
||||
|
||||
export const Components: FC = (): ReactElement => {
|
||||
return (
|
||||
<ProfileProvider profileType="user">
|
||||
<ProfileContent />
|
||||
</ProfileProvider>
|
||||
);
|
||||
};
|
||||
|
||||
const ProfileContent: FC = (): ReactElement => {
|
||||
const { isLoading, error } = 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 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);
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<main className="min-h-screen flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto"></div>
|
||||
<p className="mt-4 text-gray-600">Loading profile...</p>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<main className="min-h-screen flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<p className="text-red-600 text-lg">Failed to load profile</p>
|
||||
<p className="text-gray-600 mt-2">Please try again later.</p>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="min-h-screen">
|
||||
<div className="">
|
||||
<div className="w-full px-8 md:px-[60px] lg:px-20 py-4">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<Button variant="primary" className="flex items-center gap-2">
|
||||
<ArrowLeftOutlined />
|
||||
Kembali ke Dashboard
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="w-full px-8 md:px-[60px] lg:px-20 py-6">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<h1 className="text-2xl font-semibold text-gray-900">Your Profile</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="w-full px-8 md:px-[60px] lg:px-20 pb-12">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<div className="grid gap-8 lg:grid-cols-12">
|
||||
<div className="lg:col-span-12">
|
||||
<ProfileHeader onEditProfileClick={openEditProfileModal} />
|
||||
</div>
|
||||
<div className="lg:col-span-8 order-1">
|
||||
<ProfileForm showNotification={showNotification} />
|
||||
</div>
|
||||
|
||||
<div className="lg:col-span-4 order-2">
|
||||
<ProfileSidebar showNotification={showNotification} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<NotificationModal
|
||||
isOpen={notification.isOpen}
|
||||
onClose={hideNotification}
|
||||
type={notification.type}
|
||||
title={notification.title}
|
||||
message={notification.message}
|
||||
header="Profile"
|
||||
/>
|
||||
{}
|
||||
<EditProfileModal
|
||||
isOpen={isEditProfileModalOpen}
|
||||
onClose={closeEditProfileModal}
|
||||
showNotification={showNotification}
|
||||
/>
|
||||
</main>
|
||||
);
|
||||
};
|
||||
|
||||
export default Components;
|
||||
@@ -0,0 +1,139 @@
|
||||
import { useCallback } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useAuthStore } from '@imphnen-frontend-service/utils';
|
||||
|
||||
export interface GoogleLoginResponse {
|
||||
access_token?: string;
|
||||
token?: string | {
|
||||
access_token: string;
|
||||
refresh_token: string;
|
||||
};
|
||||
accessToken?: string;
|
||||
refresh_token?: string;
|
||||
refreshToken?: string;
|
||||
user?: {
|
||||
id: string;
|
||||
email: string;
|
||||
fullname: string;
|
||||
avatar: string;
|
||||
birthdate?: string;
|
||||
gender?: string;
|
||||
is_active?: boolean;
|
||||
phone_number?: string;
|
||||
role?: {
|
||||
id: string;
|
||||
name: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
permissions: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}>;
|
||||
};
|
||||
[key: string]: unknown;
|
||||
};
|
||||
}
|
||||
|
||||
export const useGoogleLogin = () => {
|
||||
const navigate = useNavigate();
|
||||
const { setSession } = useAuthStore();
|
||||
|
||||
const handleGoogleLogin = useCallback(async () => {
|
||||
try {
|
||||
const baseUrl = import.meta.env.VITE_API_URL || 'http://localhost:4099';
|
||||
const callbackUrl = `${window.location.origin}/auth/google-oauth-popup`;
|
||||
|
||||
let authUrl;
|
||||
if (baseUrl.endsWith('/v1')) {
|
||||
authUrl = `${baseUrl}/auth/google/login?redirect_uri=${encodeURIComponent(callbackUrl)}`;
|
||||
} else {
|
||||
authUrl = `${baseUrl}/v1/auth/google/login?redirect_uri=${encodeURIComponent(callbackUrl)}`;
|
||||
}
|
||||
|
||||
const popup = window.open(
|
||||
authUrl,
|
||||
'google-oauth',
|
||||
'width=500,height=600,scrollbars=yes,resizable=yes'
|
||||
);
|
||||
|
||||
if (!popup) {
|
||||
toast.error('Popup diblokir. Silakan aktifkan popup untuk situs ini.');
|
||||
return;
|
||||
}
|
||||
|
||||
const handleMessage = (event: MessageEvent) => {
|
||||
if (event.origin !== window.location.origin) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.data.type === 'GOOGLE_OAUTH_SUCCESS') {
|
||||
const { payload } = event.data as { payload: GoogleLoginResponse };
|
||||
|
||||
const tokenObj = typeof payload.token === 'object' ? payload.token : null;
|
||||
const accessToken = tokenObj?.access_token || payload.access_token;
|
||||
const refreshToken = tokenObj?.refresh_token || payload.refresh_token;
|
||||
const user = payload.user;
|
||||
|
||||
if (accessToken && refreshToken && user && typeof accessToken === 'string' && typeof refreshToken === 'string') {
|
||||
// Convert Google user data to match TUserItem structure
|
||||
const convertedUser = {
|
||||
id: user.id,
|
||||
avatar: user.avatar || '',
|
||||
birthdate: user.birthdate || '',
|
||||
email: user.email,
|
||||
fullname: user.fullname,
|
||||
gender: user.gender || '',
|
||||
is_active: user.is_active ?? true,
|
||||
phone_number: user.phone_number || '',
|
||||
role: user.role || {
|
||||
id: '',
|
||||
name: 'User',
|
||||
created_at: '',
|
||||
updated_at: '',
|
||||
permissions: []
|
||||
}
|
||||
};
|
||||
|
||||
// Use setSession like credential login does
|
||||
setSession({
|
||||
token: {
|
||||
access_token: accessToken,
|
||||
refresh_token: refreshToken,
|
||||
},
|
||||
user: convertedUser,
|
||||
});
|
||||
|
||||
toast.success('Login berhasil!');
|
||||
navigate(0); // Same as credential login
|
||||
} else {
|
||||
toast.error('Data login tidak lengkap');
|
||||
}
|
||||
|
||||
window.removeEventListener('message', handleMessage);
|
||||
} else if (event.data.type === 'GOOGLE_OAUTH_ERROR') {
|
||||
const { error } = event.data;
|
||||
toast.error(`Login gagal: ${error}`);
|
||||
window.removeEventListener('message', handleMessage);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('message', handleMessage);
|
||||
|
||||
setTimeout(() => {
|
||||
window.removeEventListener('message', handleMessage);
|
||||
toast.error('Login timeout. Silakan coba lagi.');
|
||||
}, 300000);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Google login error:', error);
|
||||
toast.error('Terjadi kesalahan saat login dengan Google');
|
||||
}
|
||||
}, [navigate, setSession]);
|
||||
|
||||
return {
|
||||
handleGoogleLogin,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,81 @@
|
||||
import { FC, ReactElement, useEffect } from 'react';
|
||||
import { useSearchParams, useNavigate } from 'react-router-dom';
|
||||
import { useAuthStore } from '@imphnen-frontend-service/utils';
|
||||
import { useGoogleCallback } from '@imphnen-frontend-service/service';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
export const GoogleCallbackPage: FC = (): ReactElement => {
|
||||
const [searchParams] = useSearchParams();
|
||||
const navigate = useNavigate();
|
||||
const { setSession, clearSession } = useAuthStore();
|
||||
const { mutate: googleCallback } = useGoogleCallback();
|
||||
|
||||
useEffect(() => {
|
||||
const handleCallback = async () => {
|
||||
const code = searchParams.get('code');
|
||||
const state = searchParams.get('state');
|
||||
const error = searchParams.get('error');
|
||||
|
||||
if (error) {
|
||||
toast.error('Google login dibatalkan atau terjadi kesalahan');
|
||||
navigate('/auth/login');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!code || !state) {
|
||||
toast.error('Parameter login Google tidak valid');
|
||||
navigate('/auth/login');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
googleCallback(
|
||||
{ code, state },
|
||||
{
|
||||
onSuccess: (response) => {
|
||||
if (response.token && response.user) {
|
||||
setSession({
|
||||
token: response.token,
|
||||
user: response.user,
|
||||
});
|
||||
toast.success('Login Google berhasil!');
|
||||
navigate('/dashboard');
|
||||
} else {
|
||||
throw new Error('Response data tidak valid');
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error('Google OAuth callback error:', error);
|
||||
toast.error('Login Google gagal');
|
||||
clearSession();
|
||||
navigate('/auth/login');
|
||||
},
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Google OAuth callback error:', error);
|
||||
toast.error('Login Google gagal');
|
||||
clearSession();
|
||||
navigate('/auth/login');
|
||||
}
|
||||
};
|
||||
|
||||
handleCallback();
|
||||
}, [searchParams, navigate, setSession, clearSession, googleCallback]);
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-32 w-32 border-b-2 border-primary-500 mx-auto mb-4"></div>
|
||||
<h2 className="text-2xl font-semibold text-primary-500 mb-2">
|
||||
Menyelesaikan Login Google...
|
||||
</h2>
|
||||
<p className="text-gray-600">
|
||||
Mohon tunggu sebentar, kami sedang memproses login Anda.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default GoogleCallbackPage;
|
||||
@@ -0,0 +1,201 @@
|
||||
import { FC, ReactElement, useEffect } from 'react';
|
||||
|
||||
let globalIsProcessed = false;
|
||||
|
||||
export const GoogleOAuthPopupPage: FC = (): ReactElement => {
|
||||
|
||||
useEffect(() => {
|
||||
if (globalIsProcessed) {
|
||||
return;
|
||||
}
|
||||
|
||||
const callBackend = async (code: string, state: string) => {
|
||||
if (globalIsProcessed) {
|
||||
return;
|
||||
}
|
||||
|
||||
globalIsProcessed = true;
|
||||
|
||||
try {
|
||||
const baseUrl = import.meta.env.VITE_API_URL || 'http://localhost:4099';
|
||||
let callbackUrl;
|
||||
|
||||
if (baseUrl.endsWith('/v1')) {
|
||||
callbackUrl = `${baseUrl}/auth/google/callback`;
|
||||
} else {
|
||||
callbackUrl = `${baseUrl}/v1/auth/google/callback`;
|
||||
}
|
||||
|
||||
const url = new URL(callbackUrl);
|
||||
url.searchParams.append('code', code);
|
||||
url.searchParams.append('state', state);
|
||||
url.searchParams.append('redirect_uri', `${window.location.origin}/auth/google-oauth-popup`);
|
||||
|
||||
const response = await fetch(url.toString(), {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
mode: 'cors',
|
||||
credentials: 'omit',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText} - ${errorText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
window.opener?.postMessage(
|
||||
{
|
||||
type: 'GOOGLE_OAUTH_SUCCESS',
|
||||
payload: data,
|
||||
},
|
||||
window.location.origin
|
||||
);
|
||||
window.close();
|
||||
|
||||
} catch (error) {
|
||||
globalIsProcessed = false;
|
||||
|
||||
window.opener?.postMessage(
|
||||
{
|
||||
type: 'GOOGLE_OAUTH_ERROR',
|
||||
error: `Failed to process OAuth callback: ${error instanceof Error ? error.message : String(error)}`,
|
||||
},
|
||||
window.location.origin
|
||||
);
|
||||
window.close();
|
||||
}
|
||||
};
|
||||
|
||||
const handleOAuthResponse = () => {
|
||||
const isPopup = window.opener && window.opener !== window;
|
||||
|
||||
const detectJsonResponse = () => {
|
||||
try {
|
||||
const bodyText = document.body.innerText || document.body.textContent || '';
|
||||
const trimmedText = bodyText.trim();
|
||||
|
||||
if (trimmedText.startsWith('{') && trimmedText.endsWith('}')) {
|
||||
const parsedJson = JSON.parse(trimmedText);
|
||||
|
||||
if (parsedJson && typeof parsedJson === 'object') {
|
||||
const hasAccessToken = parsedJson.access_token || parsedJson.token || parsedJson.accessToken;
|
||||
|
||||
if (hasAccessToken) {
|
||||
return parsedJson;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const checkOAuthParams = () => {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const code = urlParams.get('code');
|
||||
const state = urlParams.get('state');
|
||||
const error = urlParams.get('error');
|
||||
|
||||
if (error) {
|
||||
if (isPopup) {
|
||||
window.opener?.postMessage(
|
||||
{
|
||||
type: 'GOOGLE_OAUTH_ERROR',
|
||||
error: error,
|
||||
},
|
||||
window.location.origin
|
||||
);
|
||||
window.close();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (code && state) {
|
||||
if (isPopup) {
|
||||
callBackend(code, state);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
const immediateJson = detectJsonResponse();
|
||||
if (immediateJson && isPopup) {
|
||||
window.opener?.postMessage(
|
||||
{
|
||||
type: 'GOOGLE_OAUTH_SUCCESS',
|
||||
payload: immediateJson,
|
||||
},
|
||||
window.location.origin
|
||||
);
|
||||
window.close();
|
||||
return;
|
||||
}
|
||||
|
||||
if (checkOAuthParams()) {
|
||||
return;
|
||||
}
|
||||
|
||||
let attempts = 0;
|
||||
const maxAttempts = 50;
|
||||
|
||||
const checkForJson = () => {
|
||||
attempts++;
|
||||
const jsonResponse = detectJsonResponse();
|
||||
|
||||
if (jsonResponse && isPopup) {
|
||||
window.opener?.postMessage(
|
||||
{
|
||||
type: 'GOOGLE_OAUTH_SUCCESS',
|
||||
payload: jsonResponse,
|
||||
},
|
||||
window.location.origin
|
||||
);
|
||||
window.close();
|
||||
return;
|
||||
}
|
||||
|
||||
if (attempts < maxAttempts) {
|
||||
setTimeout(checkForJson, 500);
|
||||
} else if (isPopup) {
|
||||
window.opener?.postMessage(
|
||||
{
|
||||
type: 'GOOGLE_OAUTH_ERROR',
|
||||
error: 'Timeout waiting for response',
|
||||
},
|
||||
window.location.origin
|
||||
);
|
||||
window.close();
|
||||
}
|
||||
};
|
||||
|
||||
setTimeout(checkForJson, 1000);
|
||||
};
|
||||
|
||||
// Small delay to ensure DOM is ready
|
||||
setTimeout(handleOAuthResponse, 100);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-16 w-16 border-b-2 border-primary-500 mx-auto mb-4"></div>
|
||||
<h2 className="text-lg font-semibold text-primary-500 mb-2">
|
||||
Memproses Login Google...
|
||||
</h2>
|
||||
<p className="text-gray-600 text-sm">
|
||||
Jangan tutup jendela ini.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default GoogleOAuthPopupPage;
|
||||
@@ -2,10 +2,12 @@ import { FC, ReactElement } from 'react';
|
||||
import { ControlledInputField, LoginBanner } from '@imphnen-frontend-service/ui/organisms';
|
||||
import { Button } from '@imphnen-frontend-service/ui/atoms';
|
||||
import { useLogin } from '../../_hooks/use-login';
|
||||
import { useGoogleLogin } from '../../_hooks/use-google-login';
|
||||
import { ArrowLeftOutlined } from '@ant-design/icons';
|
||||
|
||||
export const Components: FC = (): ReactElement => {
|
||||
const { form, onSubmit, isLoading } = useLogin();
|
||||
const { handleGoogleLogin } = useGoogleLogin();
|
||||
|
||||
return (
|
||||
<div className="flex flex-col justify-center items-center min-h-screen py-[60px] px-[80px]">
|
||||
@@ -25,8 +27,8 @@ export const Components: FC = (): ReactElement => {
|
||||
label="Email"
|
||||
size="lg"
|
||||
className="w-full mb-2"
|
||||
placeholder="Masukkan email-mu, Senpai~! ✨ (Pastikan tidak typo, ya~ 😆)"
|
||||
name={'email'}
|
||||
placeholder="Masukkan email-mu, Senpai~! ✨ (Pastikan tidak typo, ya~ 😆)"
|
||||
name={'email'}
|
||||
/>
|
||||
<ControlledInputField
|
||||
control={form.control}
|
||||
@@ -44,7 +46,7 @@ export const Components: FC = (): ReactElement => {
|
||||
</div>
|
||||
<Button className="w-full" type='submit' disabled={(!form.formState.isValid || isLoading)}>Enter Isekai</Button>
|
||||
</form>
|
||||
|
||||
|
||||
<div className="flex my-3 gap-3 justify-center">
|
||||
<h5>Belum Punya akun ?</h5>
|
||||
<a href="/auth/register" className="text-primary-500 font-medium">
|
||||
@@ -64,6 +66,7 @@ export const Components: FC = (): ReactElement => {
|
||||
<Button
|
||||
className="w-full my-3 text-gray-500 gap-2"
|
||||
variant="secondary"
|
||||
onClick={handleGoogleLogin}
|
||||
>
|
||||
<p>Log In With Google</p>
|
||||
<img
|
||||
|
||||
@@ -10,17 +10,17 @@ const mappingPublicRoutes = [
|
||||
'/auth/login',
|
||||
'/auth/forgot',
|
||||
'/auth/forgot/otp',
|
||||
'/auth/register',
|
||||
'/auth/register',
|
||||
'/auth/register/otp',
|
||||
'/auth/register/success',
|
||||
'/auth/new-password',
|
||||
'/auth/register-mentor',
|
||||
'/auth/register-mentor/pending',
|
||||
'/auth/register-mentor/success',
|
||||
'/auth/google-callback',
|
||||
'/auth/google-oauth-popup',
|
||||
'/resources',
|
||||
];
|
||||
|
||||
const mappingRoutePermissions = [
|
||||
];const mappingRoutePermissions = [
|
||||
{
|
||||
path: '/dashboard',
|
||||
permissions: [],
|
||||
@@ -96,7 +96,7 @@ export const middleware = async ({ request }: LoaderFunctionArgs) => {
|
||||
const token = session_token?.token?.access_token;
|
||||
const userPermissions =
|
||||
session?.role?.permissions?.map?.((perm) => perm?.name) ?? [];
|
||||
|
||||
|
||||
// Allow to access the landing page without authentication
|
||||
// So, if the route prefix is in the mappingPublicPrefixRoutes, we return null
|
||||
// to indicate that we don't need to authenticate the user
|
||||
|
||||
+1186
-13
File diff suppressed because it is too large
Load Diff
@@ -5,6 +5,7 @@ import {
|
||||
TRegisterRequest,
|
||||
TSendOTPRequest,
|
||||
TVerifyEmailRequest,
|
||||
TGoogleCallbackResponse,
|
||||
} from '../../types/auth';
|
||||
import { TResponseMessage } from '../../types/common';
|
||||
|
||||
@@ -51,3 +52,16 @@ export const postSendOtp = async (
|
||||
});
|
||||
return data;
|
||||
};
|
||||
|
||||
export const getGoogleAuthUrl = async (): Promise<string> => {
|
||||
const baseUrl = import.meta.env.VITE_API_URL || 'http://localhost:8080';
|
||||
return `${baseUrl}/auth/google/login`;
|
||||
};
|
||||
|
||||
export const postGoogleCallback = async (code: string, state: string): Promise<TGoogleCallbackResponse> => {
|
||||
const { data } = await api({
|
||||
method: 'GET',
|
||||
url: `/auth/google/callback?code=${code}&state=${state}`,
|
||||
});
|
||||
return data;
|
||||
};
|
||||
|
||||
@@ -3,9 +3,140 @@ import axios, { AxiosRequestConfig } from 'axios';
|
||||
export * from './auth';
|
||||
export * from './gacha';
|
||||
export * from './users';
|
||||
export * from './mentors';
|
||||
export * from './upload';
|
||||
|
||||
// Common API response wrapper interface
|
||||
export interface ApiResponse<T> {
|
||||
data: T;
|
||||
version: string;
|
||||
}
|
||||
|
||||
const TOKEN_KEY = 'token';
|
||||
|
||||
// Helper functions for session management (avoiding circular dependency)
|
||||
const getSessionTokenFromCookies = () => {
|
||||
if (typeof document === 'undefined') return null;
|
||||
|
||||
const cookies = document.cookie.split(';');
|
||||
const tokenCookie = cookies.find(cookie => cookie.trim().startsWith(`${TOKEN_KEY}=`));
|
||||
|
||||
if (!tokenCookie) return null;
|
||||
|
||||
try {
|
||||
const tokenValue = tokenCookie.split('=')[1];
|
||||
return JSON.parse(decodeURIComponent(tokenValue));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const setSessionTokenToCookies = (tokenData: { token: { access_token: string; refresh_token: string } }) => {
|
||||
if (typeof document === 'undefined') return;
|
||||
|
||||
const expires = new Date();
|
||||
expires.setDate(expires.getDate() + 7);
|
||||
|
||||
document.cookie = `${TOKEN_KEY}=${encodeURIComponent(JSON.stringify(tokenData))}; expires=${expires.toUTCString()}; path=/; secure; samesite=strict`;
|
||||
};
|
||||
|
||||
const removeSessionTokenFromCookies = () => {
|
||||
if (typeof document === 'undefined') return;
|
||||
|
||||
document.cookie = `${TOKEN_KEY}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;`;
|
||||
};
|
||||
|
||||
const config: AxiosRequestConfig = {
|
||||
baseURL: import.meta.env.VITE_API_URL,
|
||||
};
|
||||
|
||||
export const api = axios.create(config);
|
||||
|
||||
// Add request interceptor to include authentication token
|
||||
api.interceptors.request.use(
|
||||
(config) => {
|
||||
const sessionData = getSessionTokenFromCookies();
|
||||
const token = sessionData?.token?.access_token;
|
||||
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
return config;
|
||||
},
|
||||
(error) => {
|
||||
return Promise.reject(new Error(error.message || 'Request failed'));
|
||||
}
|
||||
);
|
||||
|
||||
// Add response interceptor to handle token refresh
|
||||
api.interceptors.response.use(
|
||||
(response) => response,
|
||||
async (error) => {
|
||||
const originalRequest = error.config;
|
||||
|
||||
if (error.response?.status === 401 && !originalRequest._retry) {
|
||||
originalRequest._retry = true;
|
||||
return handleTokenRefresh(originalRequest);
|
||||
}
|
||||
|
||||
// If backend sends a message, use it
|
||||
const backendMsg = error?.response?.data?.message;
|
||||
if (backendMsg && typeof backendMsg === 'string') {
|
||||
return Promise.reject(new Error(backendMsg));
|
||||
}
|
||||
|
||||
return Promise.reject(new Error(error.message || 'Request failed'));
|
||||
}
|
||||
);
|
||||
|
||||
// Helper function to handle token refresh
|
||||
async function handleTokenRefresh(originalRequest: AxiosRequestConfig) {
|
||||
const sessionData = getSessionTokenFromCookies();
|
||||
const refreshToken = sessionData?.token?.refresh_token;
|
||||
|
||||
if (!refreshToken) {
|
||||
clearSessionAndRedirect();
|
||||
throw new Error('No refresh token available');
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await refreshAccessToken(refreshToken);
|
||||
|
||||
if (response.data?.access_token) {
|
||||
// Update session in cookies
|
||||
setSessionTokenToCookies({
|
||||
token: {
|
||||
access_token: response.data.access_token,
|
||||
refresh_token: response.data.refresh_token || refreshToken,
|
||||
},
|
||||
});
|
||||
|
||||
// Retry original request with new token
|
||||
originalRequest.headers ??= {};
|
||||
originalRequest.headers.Authorization = `Bearer ${response.data.access_token}`;
|
||||
return api(originalRequest);
|
||||
}
|
||||
|
||||
throw new Error('Invalid refresh response');
|
||||
} catch (refreshError) {
|
||||
console.error('Token refresh failed:', refreshError);
|
||||
clearSessionAndRedirect();
|
||||
throw new Error('Token refresh failed');
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to refresh access token
|
||||
async function refreshAccessToken(refreshToken: string) {
|
||||
return axios.post(`${import.meta.env.VITE_API_URL}/auth/refresh`, {
|
||||
refresh_token: refreshToken,
|
||||
});
|
||||
}
|
||||
|
||||
// Helper function to clear session and redirect
|
||||
function clearSessionAndRedirect() {
|
||||
removeSessionTokenFromCookies();
|
||||
if (typeof window !== 'undefined') {
|
||||
window.location.href = '/auth/login';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { api, ApiResponse } from '../index';
|
||||
import type {
|
||||
MentorDetailResponseDto,
|
||||
MentorUpdateRequestDto
|
||||
} from '../../types/mentors';
|
||||
|
||||
export interface MentorService {
|
||||
getMentorMe(): Promise<MentorDetailResponseDto>;
|
||||
getMentorById(id: string): Promise<MentorDetailResponseDto>;
|
||||
updateMentorMe(data: MentorUpdateRequestDto): Promise<MentorDetailResponseDto>;
|
||||
updateMentorById(id: string, data: MentorUpdateRequestDto): Promise<MentorDetailResponseDto>;
|
||||
}
|
||||
|
||||
export const mentorService: MentorService = {
|
||||
async getMentorMe() {
|
||||
const response = await api.get<ApiResponse<MentorDetailResponseDto>>('/mentors/me');
|
||||
return response.data.data;
|
||||
},
|
||||
|
||||
async getMentorById(id: string) {
|
||||
const response = await api.get<ApiResponse<MentorDetailResponseDto>>(`/mentors/detail/${id}`);
|
||||
return response.data.data;
|
||||
},
|
||||
|
||||
async updateMentorMe(data: MentorUpdateRequestDto) {
|
||||
const response = await api.put<ApiResponse<MentorDetailResponseDto>>('/mentors/update/me', data);
|
||||
return response.data.data;
|
||||
},
|
||||
|
||||
async updateMentorById(id: string, data: MentorUpdateRequestDto) {
|
||||
const response = await api.put<ApiResponse<MentorDetailResponseDto>>(`/mentors/update/${id}`, data);
|
||||
return response.data.data;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,63 @@
|
||||
import { api, ApiResponse } from '../index';
|
||||
|
||||
export interface UploadResponse {
|
||||
filename: string;
|
||||
original_filename: string;
|
||||
uploaded_path: string;
|
||||
url: string;
|
||||
size: number;
|
||||
content_type: string;
|
||||
file_type: string;
|
||||
user_id: string;
|
||||
email: string;
|
||||
}
|
||||
|
||||
export interface UploadService {
|
||||
uploadFile(file: File): Promise<UploadResponse>;
|
||||
uploadAvatar(file: File): Promise<UploadResponse>;
|
||||
uploadCV(file: File): Promise<UploadResponse>;
|
||||
}
|
||||
|
||||
export const uploadService: UploadService = {
|
||||
async uploadFile(file: File) {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
const response = await api.post<ApiResponse<UploadResponse>>('/users/upload', formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
});
|
||||
return response.data.data;
|
||||
},
|
||||
|
||||
async uploadAvatar(file: File) {
|
||||
// Validate file type
|
||||
if (!file.type.startsWith('image/')) {
|
||||
throw new Error('File harus berupa gambar');
|
||||
}
|
||||
|
||||
// Validate file size (max 5MB for images)
|
||||
const maxSize = 5 * 1024 * 1024; // 5MB
|
||||
if (file.size > maxSize) {
|
||||
throw new Error('Ukuran file maksimal 5MB');
|
||||
}
|
||||
|
||||
return this.uploadFile(file);
|
||||
},
|
||||
|
||||
async uploadCV(file: File) {
|
||||
// Validate file type
|
||||
if (file.type !== 'application/pdf') {
|
||||
throw new Error('CV harus berupa file PDF');
|
||||
}
|
||||
|
||||
// Validate file size (max 10MB for PDFs)
|
||||
const maxSize = 10 * 1024 * 1024; // 10MB
|
||||
if (file.size > maxSize) {
|
||||
throw new Error('Ukuran file maksimal 10MB');
|
||||
}
|
||||
|
||||
return this.uploadFile(file);
|
||||
},
|
||||
};
|
||||
@@ -1 +1,89 @@
|
||||
export {};
|
||||
import { api, ApiResponse } from '../index';
|
||||
import { TUserItem } from '../../types/users';
|
||||
|
||||
export interface UserDetailResponseDto extends TUserItem {
|
||||
bio?: string;
|
||||
location?: string;
|
||||
website_url?: string;
|
||||
linkedin_url?: string;
|
||||
github_url?: string;
|
||||
twitter_url?: string;
|
||||
skills?: string[];
|
||||
career_status?: string;
|
||||
experience?: Array<{
|
||||
id: string;
|
||||
company: string;
|
||||
position: string;
|
||||
duration: string;
|
||||
period: string;
|
||||
}>;
|
||||
education?: Array<{
|
||||
id: string;
|
||||
institution: string;
|
||||
degree: string;
|
||||
field: string;
|
||||
period: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface UserUpdateRequestDto {
|
||||
fullname?: string;
|
||||
bio?: string;
|
||||
location?: string;
|
||||
website_url?: string;
|
||||
linkedin_url?: string;
|
||||
github_url?: string;
|
||||
twitter_url?: string;
|
||||
skills?: string[];
|
||||
phone_number?: string;
|
||||
birthdate?: string;
|
||||
gender?: string;
|
||||
career_status?: string;
|
||||
avatar?: string;
|
||||
cv_url?: string;
|
||||
phone_for_verification?: string;
|
||||
domicile?: string;
|
||||
experience?: Array<{
|
||||
id: string;
|
||||
company: string;
|
||||
position: string;
|
||||
duration: string;
|
||||
period: string;
|
||||
}>;
|
||||
education?: Array<{
|
||||
id: string;
|
||||
institution: string;
|
||||
degree: string;
|
||||
field: string;
|
||||
period: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface UserService {
|
||||
getUserMe(): Promise<UserDetailResponseDto>;
|
||||
getUserById(id: string): Promise<UserDetailResponseDto>;
|
||||
updateUserMe(data: UserUpdateRequestDto): Promise<UserDetailResponseDto>;
|
||||
updateUserById(id: string, data: UserUpdateRequestDto): Promise<UserDetailResponseDto>;
|
||||
}
|
||||
|
||||
export const userService: UserService = {
|
||||
async getUserMe() {
|
||||
const response = await api.get<ApiResponse<UserDetailResponseDto>>('/users/me');
|
||||
return response.data.data;
|
||||
},
|
||||
|
||||
async getUserById(id: string) {
|
||||
const response = await api.get<ApiResponse<UserDetailResponseDto>>(`/users/detail/${id}`);
|
||||
return response.data.data;
|
||||
},
|
||||
|
||||
async updateUserMe(data: UserUpdateRequestDto) {
|
||||
const response = await api.put<ApiResponse<UserDetailResponseDto>>('/users/update/me', data);
|
||||
return response.data.data;
|
||||
},
|
||||
|
||||
async updateUserById(id: string, data: UserUpdateRequestDto) {
|
||||
const response = await api.put<ApiResponse<UserDetailResponseDto>>(`/users/${id}`, data);
|
||||
return response.data.data;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { useMutation, UseMutationResult } from '@tanstack/react-query';
|
||||
import { postLogin, postRegister, postSendOtp, postVerifyEmail } from '../../api/auth';
|
||||
import { postLogin, postRegister, postSendOtp, postVerifyEmail, getGoogleAuthUrl, postGoogleCallback } from '../../api/auth';
|
||||
import {
|
||||
TLoginRequest,
|
||||
TLoginResponse,
|
||||
TRegisterRequest,
|
||||
TSendOTPRequest,
|
||||
TVerifyEmailRequest,
|
||||
TGoogleCallbackResponse,
|
||||
} from '../../types/auth';
|
||||
|
||||
import { TResponseError, TResponseMessage } from '../../types/common';
|
||||
@@ -21,7 +22,7 @@ export const usePostLogin = (): UseMutationResult<
|
||||
mutationFn: async (payload) => await postLogin(payload),
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
export const usePostRegister = (): UseMutationResult<
|
||||
TResponseMessage,
|
||||
TResponseError,
|
||||
@@ -58,3 +59,25 @@ export const usePostSendOTP = (): UseMutationResult<
|
||||
});
|
||||
};
|
||||
|
||||
export const useGoogleAuth = () => {
|
||||
const redirectToGoogle = async (): Promise<string> => {
|
||||
return await getGoogleAuthUrl();
|
||||
};
|
||||
|
||||
return {
|
||||
redirectToGoogle,
|
||||
};
|
||||
};
|
||||
|
||||
export const useGoogleCallback = (): UseMutationResult<
|
||||
TGoogleCallbackResponse,
|
||||
TResponseError,
|
||||
{ code: string; state: string },
|
||||
unknown
|
||||
> => {
|
||||
return useMutation({
|
||||
mutationKey: ['google-callback'],
|
||||
mutationFn: async ({ code, state }) => await postGoogleCallback(code, state),
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
export * from './auth';
|
||||
export * from './gacha';
|
||||
export * from './users';
|
||||
export * from './mentors';
|
||||
export * from './upload';
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { useQuery, useMutation, UseQueryResult, UseMutationResult, UseQueryOptions } from '@tanstack/react-query';
|
||||
import { mentorService } from '../../api/mentors';
|
||||
import { MentorDetailResponseDto, MentorUpdateRequestDto } from '../../types/mentors';
|
||||
import { TResponseError } from '../../types/common';
|
||||
|
||||
export const useMentorMe = (options?: UseQueryOptions<MentorDetailResponseDto, TResponseError>): UseQueryResult<MentorDetailResponseDto, TResponseError> => {
|
||||
return useQuery({
|
||||
queryKey: ['mentor-me'],
|
||||
queryFn: () => mentorService.getMentorMe(),
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
export const useMentorById = (id: string, options?: UseQueryOptions<MentorDetailResponseDto, TResponseError>): UseQueryResult<MentorDetailResponseDto, TResponseError> => {
|
||||
return useQuery({
|
||||
queryKey: ['mentor-by-id', id],
|
||||
queryFn: () => mentorService.getMentorById(id),
|
||||
enabled: !!id,
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
export const useUpdateMentorMe = (): UseMutationResult<
|
||||
MentorDetailResponseDto,
|
||||
TResponseError,
|
||||
MentorUpdateRequestDto,
|
||||
unknown
|
||||
> => {
|
||||
return useMutation({
|
||||
mutationKey: ['update-mentor-me'],
|
||||
mutationFn: (data) => mentorService.updateMentorMe(data),
|
||||
});
|
||||
};
|
||||
|
||||
export const useUpdateMentorById = (): UseMutationResult<
|
||||
MentorDetailResponseDto,
|
||||
TResponseError,
|
||||
{ id: string; data: MentorUpdateRequestDto },
|
||||
unknown
|
||||
> => {
|
||||
return useMutation({
|
||||
mutationKey: ['update-mentor-by-id'],
|
||||
mutationFn: ({ id, data }) => mentorService.updateMentorById(id, data),
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,39 @@
|
||||
import { useMutation, UseMutationResult } from '@tanstack/react-query';
|
||||
import { uploadService, UploadResponse } from '../../api/upload';
|
||||
import { TResponseError } from '../../types/common';
|
||||
|
||||
export const useUploadFile = (): UseMutationResult<
|
||||
UploadResponse,
|
||||
TResponseError,
|
||||
File,
|
||||
unknown
|
||||
> => {
|
||||
return useMutation({
|
||||
mutationKey: ['upload-file'],
|
||||
mutationFn: (file) => uploadService.uploadFile(file),
|
||||
});
|
||||
};
|
||||
|
||||
export const useUploadAvatar = (): UseMutationResult<
|
||||
UploadResponse,
|
||||
TResponseError,
|
||||
File,
|
||||
unknown
|
||||
> => {
|
||||
return useMutation({
|
||||
mutationKey: ['upload-avatar'],
|
||||
mutationFn: (file) => uploadService.uploadAvatar(file),
|
||||
});
|
||||
};
|
||||
|
||||
export const useUploadCV = (): UseMutationResult<
|
||||
UploadResponse,
|
||||
TResponseError,
|
||||
File,
|
||||
unknown
|
||||
> => {
|
||||
return useMutation({
|
||||
mutationKey: ['upload-cv'],
|
||||
mutationFn: (file) => uploadService.uploadCV(file),
|
||||
});
|
||||
};
|
||||
@@ -1 +1,44 @@
|
||||
export {};
|
||||
import { useQuery, useMutation, UseQueryResult, UseMutationResult, UseQueryOptions } from '@tanstack/react-query';
|
||||
import { userService, UserDetailResponseDto, UserUpdateRequestDto } from '../../api/users';
|
||||
import { TResponseError } from '../../types/common';
|
||||
|
||||
export const useUserMe = (options?: UseQueryOptions<UserDetailResponseDto, TResponseError>): UseQueryResult<UserDetailResponseDto, TResponseError> => {
|
||||
return useQuery({
|
||||
queryKey: ['user-me'],
|
||||
queryFn: () => userService.getUserMe(),
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
export const useUserById = (id: string, options?: UseQueryOptions<UserDetailResponseDto, TResponseError>): UseQueryResult<UserDetailResponseDto, TResponseError> => {
|
||||
return useQuery({
|
||||
queryKey: ['user-by-id', id],
|
||||
queryFn: () => userService.getUserById(id),
|
||||
enabled: !!id,
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
export const useUpdateUserMe = (): UseMutationResult<
|
||||
UserDetailResponseDto,
|
||||
TResponseError,
|
||||
UserUpdateRequestDto,
|
||||
unknown
|
||||
> => {
|
||||
return useMutation({
|
||||
mutationKey: ['update-user-me'],
|
||||
mutationFn: (data) => userService.updateUserMe(data),
|
||||
});
|
||||
};
|
||||
|
||||
export const useUpdateUserById = (): UseMutationResult<
|
||||
UserDetailResponseDto,
|
||||
TResponseError,
|
||||
{ id: string; data: UserUpdateRequestDto },
|
||||
unknown
|
||||
> => {
|
||||
return useMutation({
|
||||
mutationKey: ['update-user-by-id'],
|
||||
mutationFn: ({ id, data }) => userService.updateUserById(id, data),
|
||||
});
|
||||
};
|
||||
|
||||
@@ -30,4 +30,9 @@ export type TVerifyEmailRequest = {
|
||||
|
||||
export type TSendOTPRequest = {
|
||||
email: string
|
||||
};
|
||||
};
|
||||
|
||||
export type TGoogleCallbackResponse = {
|
||||
token: TTokenItem;
|
||||
user: TUserItem;
|
||||
};
|
||||
|
||||
@@ -3,3 +3,4 @@ export * from './gacha';
|
||||
export * from './users';
|
||||
export * from './roles';
|
||||
export * from './permissions';
|
||||
export * from './mentors';
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
export interface MentoringRate {
|
||||
amount: number;
|
||||
currency: string;
|
||||
}
|
||||
|
||||
export interface MentorDetailResponseDto {
|
||||
availability_commitment: string;
|
||||
bio?: string | null;
|
||||
created_at: string;
|
||||
current_company: string;
|
||||
current_role: string;
|
||||
cv_url?: string | null;
|
||||
domicile?: string | null;
|
||||
email?: string | null;
|
||||
expertise: string[];
|
||||
fullname?: string | null;
|
||||
gender?: string | null;
|
||||
github_url?: string | null;
|
||||
id: string;
|
||||
industries: string[];
|
||||
languages: string[];
|
||||
last_education?: string | null;
|
||||
legal_name?: string | null;
|
||||
linkedin_url?: string | null;
|
||||
mentoring_rate: MentoringRate;
|
||||
phone_for_verification?: string | null;
|
||||
portfolio_url?: string | null;
|
||||
preferred_mentee_level: string[];
|
||||
preferred_mentoring_formats: string[];
|
||||
status: string;
|
||||
topics_of_interest: string[];
|
||||
updated_at: string;
|
||||
user_id: string;
|
||||
years_of_experience: number;
|
||||
mentoring_sessions?: number;
|
||||
rating?: number;
|
||||
experience?: Array<{
|
||||
id: string;
|
||||
company: string;
|
||||
position: string;
|
||||
duration: string;
|
||||
period: string;
|
||||
}>;
|
||||
education?: Array<{
|
||||
id: string;
|
||||
institution: string;
|
||||
degree: string;
|
||||
field: string;
|
||||
period: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface MentorUpdateRequestDto {
|
||||
availability_commitment?: string | null;
|
||||
bio?: string | null;
|
||||
current_company?: string | null;
|
||||
current_role?: string | null;
|
||||
cv_url?: string | null;
|
||||
domicile?: string | null;
|
||||
expertise?: string[] | null;
|
||||
gender?: string | null;
|
||||
github_url?: string | null;
|
||||
industries?: string[] | null;
|
||||
languages?: string[] | null;
|
||||
last_education?: string | null;
|
||||
legal_name?: string | null;
|
||||
linkedin_url?: string | null;
|
||||
mentoring_rate_amount?: number | null;
|
||||
phone_for_verification?: string | null;
|
||||
portfolio_url?: string | null;
|
||||
preferred_mentee_level?: string[] | null;
|
||||
preferred_mentoring_formats?: string[] | null;
|
||||
topics_of_interest?: string[] | null;
|
||||
years_of_experience?: number | null;
|
||||
experience?: Array<{
|
||||
id: string;
|
||||
company: string;
|
||||
position: string;
|
||||
duration: string;
|
||||
period: string;
|
||||
}>;
|
||||
education?: Array<{
|
||||
id: string;
|
||||
institution: string;
|
||||
degree: string;
|
||||
field: string;
|
||||
period: string;
|
||||
}>;
|
||||
}
|
||||
@@ -11,3 +11,6 @@ export type TUserItem = {
|
||||
phone_number: string;
|
||||
role: TRoleDetailItem;
|
||||
};
|
||||
|
||||
// Re-export types from API for convenience
|
||||
export type { UserDetailResponseDto, UserUpdateRequestDto } from '../../api/users';
|
||||
|
||||
Reference in New Issue
Block a user