Compare commits

..
173 changed files with 13064 additions and 45542 deletions
-18
View File
@@ -1,18 +0,0 @@
{
"permissions": {
"allow": [
"Bash(npx nx run-many:*)",
"Bash(npm install:*)",
"Bash(npm view:*)",
"Bash(npx nx build landing)",
"Bash(npm uninstall:*)",
"Bash(dir:*)",
"Bash(ren page.tsx page-original.tsx)",
"Bash(ren:*)",
"Bash(npx supabase:*)",
"Bash(libs/service/src/types/supabase.ts)"
],
"deny": [],
"ask": []
}
}
-55
View File
@@ -1,55 +0,0 @@
# Dependencies
node_modules
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Build outputs
dist
.next
out
build
coverage
# IDE
.vscode
.idea
*.swp
*.swo
*~
# OS
.DS_Store
Thumbs.db
# Git
.git
.gitignore
# Environment
.env
.env.local
.env.*.local
# Testing
coverage
.nyc_output
# Logs
logs
*.log
# Nx
.nx
# Docker
Dockerfile
docker-compose*.yml
.dockerignore
# Misc
*.md
!README.md
.editorconfig
.prettierrc
.eslintrc
-3
View File
@@ -1,3 +0,0 @@
VITE_SUPABASE_URL=https://your-project-id.supabase.co
VITE_SUPABASE_ANON_KEY=your-anon-key-here
+2 -6
View File
@@ -26,13 +26,9 @@ jobs:
if [ ! -d ~/imphnen-frontend-service-backoffice ]; then
git clone https://github.com/IMPHNEN/imphnen-frontend-service.git ~/imphnen-frontend-service-backoffice
else
cd ~/imphnen-frontend-service-backoffice && git fetch origin && git reset --hard origin/develop && git pull
cd ~/imphnen-frontend-service-backoffice && git pull
fi
cat > ~/imphnen-frontend-service-backoffice/apps/backoffice/.env << EOF
VITE_API_URL=${{ secrets.VITE_API_URL }}
VITE_SUPABASE_URL=${{ secrets.VITE_SUPABASE_URL }}
VITE_SUPABASE_ANON_KEY=${{ secrets.VITE_SUPABASE_ANON_KEY }}
EOF
echo "VITE_API_URL=${{ secrets.VITE_API_URL }}" > ~/imphnen-frontend-service-backoffice/apps/backoffice/.env
docker compose -f ~/imphnen-frontend-service-backoffice/docker-compose-backoffice.yml down || true
docker compose -f ~/imphnen-frontend-service-backoffice/docker-compose-backoffice.yml build
docker compose -f ~/imphnen-frontend-service-backoffice/docker-compose-backoffice.yml up -d
+2 -6
View File
@@ -26,13 +26,9 @@ jobs:
if [ ! -d ~/imphnen-frontend-service-dimentorin ]; then
git clone https://github.com/IMPHNEN/imphnen-frontend-service.git ~/imphnen-frontend-service-dimentorin
else
cd ~/imphnen-frontend-service-dimentorin && git fetch origin && git reset --hard origin/develop && git pull
cd ~/imphnen-frontend-service-dimentorin && git pull
fi
cat > ~/imphnen-frontend-service-dimentorin/apps/dimentorin/.env << EOF
VITE_API_URL=${{ secrets.VITE_API_URL }}
VITE_SUPABASE_URL=${{ secrets.VITE_SUPABASE_URL }}
VITE_SUPABASE_ANON_KEY=${{ secrets.VITE_SUPABASE_ANON_KEY }}
EOF
echo "VITE_API_URL=${{ secrets.VITE_API_URL }}" > ~/imphnen-frontend-service-dimentorin/apps/dimentorin/.env
docker compose -f ~/imphnen-frontend-service-dimentorin/docker-compose-dimentorin.yml down || true
docker compose -f ~/imphnen-frontend-service-dimentorin/docker-compose-dimentorin.yml build
docker compose -f ~/imphnen-frontend-service-dimentorin/docker-compose-dimentorin.yml up -d
+2 -6
View File
@@ -26,13 +26,9 @@ jobs:
if [ ! -d ~/imphnen-frontend-service-gacha ]; then
git clone https://github.com/IMPHNEN/imphnen-frontend-service.git ~/imphnen-frontend-service-gacha
else
cd ~/imphnen-frontend-service-gacha && git fetch origin && git reset --hard origin/develop && git pull
cd ~/imphnen-frontend-service-gacha && git pull
fi
cat > ~/imphnen-frontend-service-gacha/apps/gacha/.env << EOF
VITE_API_URL=${{ secrets.VITE_API_URL }}
VITE_SUPABASE_URL=${{ secrets.VITE_SUPABASE_URL }}
VITE_SUPABASE_ANON_KEY=${{ secrets.VITE_SUPABASE_ANON_KEY }}
EOF
echo "VITE_API_URL=${{ secrets.VITE_API_URL }}" > ~/imphnen-frontend-service-gacha/apps/gacha/.env
docker compose -f ~/imphnen-frontend-service-gacha/docker-compose-gacha.yml down || true
docker compose -f ~/imphnen-frontend-service-gacha/docker-compose-gacha.yml build
docker compose -f ~/imphnen-frontend-service-gacha/docker-compose-gacha.yml up -d
-39
View File
@@ -1,39 +0,0 @@
name: Deploy Hackathon
on:
push:
branches:
- develop
paths:
- 'apps/hackathon/**' # Auto deploy when changes pushed to apps/hackathon
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout repo
uses: actions/checkout@v4
- name: Deploy via SSH
uses: appleboy/ssh-action@v1.0.3
with:
host: ${{ secrets.SSH_HOST }}
username: ${{ secrets.SSH_USER }}
key: ${{ secrets.SSH_KEY }}
port: ${{ secrets.SSH_PORT }}
script: |
set -e
if [ ! -d ~/imphnen-frontend-service-hackathon ]; then
git clone https://github.com/IMPHNEN/imphnen-frontend-service.git ~/imphnen-frontend-service-hackathon
else
cd ~/imphnen-frontend-service-hackathon && git fetch origin && git reset --hard origin/develop && git pull
fi
cat > ~/imphnen-frontend-service-hackathon/apps/hackathon/.env << EOF
VITE_API_URL=${{ secrets.VITE_API_URL }}
VITE_SUPABASE_URL=${{ secrets.VITE_SUPABASE_URL }}
VITE_SUPABASE_ANON_KEY=${{ secrets.VITE_SUPABASE_ANON_KEY }}
EOF
docker compose -f ~/imphnen-frontend-service-hackathon/docker-compose-hackathon.yml down || true
docker compose -f ~/imphnen-frontend-service-hackathon/docker-compose-hackathon.yml build
docker compose -f ~/imphnen-frontend-service-hackathon/docker-compose-hackathon.yml up -d
docker image prune -af
+2 -6
View File
@@ -26,13 +26,9 @@ jobs:
if [ ! -d ~/imphnen-frontend-service ]; then
git clone https://github.com/IMPHNEN/imphnen-frontend-service.git ~/imphnen-frontend-service
else
cd ~/imphnen-frontend-service && git fetch origin && git reset --hard origin/develop && git pull
cd ~/imphnen-frontend-service && git pull
fi
cat > ~/imphnen-frontend-service/apps/landing/.env << EOF
NEXT_PUBLIC_API_URL=${{ secrets.NEXT_PUBLIC_API_URL }}
NEXT_PUBLIC_SUPABASE_URL=${{ secrets.NEXT_PUBLIC_SUPABASE_URL }}
NEXT_PUBLIC_SUPABASE_ANON_KEY=${{ secrets.NEXT_PUBLIC_SUPABASE_ANON_KEY }}
EOF
echo "NEXT_PUBLIC_API_URL=${{ secrets.NEXT_PUBLIC_API_URL }}" > ~/imphnen-frontend-service/apps/landing/.env
docker compose -f ~/imphnen-frontend-service/docker-compose-landing.yml down || true
docker compose -f ~/imphnen-frontend-service/docker-compose-landing.yml build
docker compose -f ~/imphnen-frontend-service/docker-compose-landing.yml up -d
-4
View File
@@ -62,7 +62,3 @@ storybook-static
# Next.js
.next
out
.cursor/rules/nx-rules.mdc
.github/instructions/nx.instructions.md
.env.local
-1
View File
@@ -1 +0,0 @@
Tue, Nov 25, 2025 11:09:55 AM
-2
View File
@@ -1,2 +0,0 @@
# Deployment trigger
# Updated to deploy circular dependency fixes and auth hooks
-1
View File
@@ -1 +0,0 @@
Tue, Nov 25, 2025 11:09:55 AM
-2
View File
@@ -1,2 +0,0 @@
# Deployment trigger
# Updated to deploy circular dependency fixes and auth hooks
Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

@@ -1,166 +0,0 @@
'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;
@@ -1,25 +0,0 @@
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>
);
};
@@ -1,2 +0,0 @@
export { EditSectionButton } from './edit-section-button';
export { ModalButton } from './modal-button';
@@ -1,76 +0,0 @@
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>
);
};
@@ -1 +0,0 @@
export * from './profile-context';
@@ -1,249 +0,0 @@
'use client';
import React, { createContext, useContext, useMemo, useCallback } from 'react';
import { useParams } from 'react-router-dom';
import { useQueryClient } from '@tanstack/react-query';
import {
useAuthStore,
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 };
@@ -1,27 +0,0 @@
'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>
);
};
@@ -1,17 +0,0 @@
export * from './profile';
export * from './sections';
export * from './buttons';
export * from './shared';
export * from './modals';
export * from './contexts';
@@ -1,187 +0,0 @@
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>
);
};
@@ -1,99 +0,0 @@
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>
);
};
@@ -1,253 +0,0 @@
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>
);
};
@@ -1,184 +0,0 @@
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>
);
};
@@ -1,182 +0,0 @@
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>
);
};
@@ -1,10 +0,0 @@
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';
@@ -1,153 +0,0 @@
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>
);
};
@@ -1,89 +0,0 @@
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>
);
};
@@ -1,137 +0,0 @@
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>
);
};
@@ -1,105 +0,0 @@
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>
);
};
@@ -1,168 +0,0 @@
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>
);
};
@@ -1,118 +0,0 @@
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>
);
};
@@ -1,8 +0,0 @@
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';
@@ -1,120 +0,0 @@
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
};
};
@@ -1,253 +0,0 @@
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]);
};
@@ -1,60 +0,0 @@
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>;
@@ -1,251 +0,0 @@
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>
);
};
@@ -1,115 +0,0 @@
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>
);
};
@@ -1,187 +0,0 @@
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>
);
};
@@ -1,190 +0,0 @@
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>
);
};
@@ -1,239 +0,0 @@
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>
);
};
@@ -1,245 +0,0 @@
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>
);
};
@@ -1,95 +0,0 @@
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>
);
};
@@ -1,105 +0,0 @@
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>
);
};
@@ -1,61 +0,0 @@
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>
);
};
@@ -1,85 +0,0 @@
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>
);
};
@@ -1,85 +0,0 @@
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>
);
};
@@ -1,8 +0,0 @@
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';
@@ -1,72 +0,0 @@
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>
);
};
@@ -1,98 +0,0 @@
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>
);
};
@@ -1,75 +0,0 @@
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>
);
};
@@ -1,69 +0,0 @@
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>
);
};
@@ -1,183 +0,0 @@
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>
);
};
@@ -1 +0,0 @@
export { SectionWrapper } from './section-wrapper';
@@ -1,26 +0,0 @@
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>
);
};
@@ -1,132 +0,0 @@
'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;
@@ -1,139 +0,0 @@
import { useCallback } from 'react';
import { toast } from 'sonner';
import { useNavigate } from 'react-router-dom';
import { useAuthStore } from '@imphnen-frontend-service/service';
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,
};
};
@@ -1,80 +0,0 @@
import { FC, ReactElement, useEffect } from 'react';
import { useSearchParams, useNavigate } from 'react-router-dom';
import { useGoogleCallback, useAuthStore } 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;
@@ -1,201 +0,0 @@
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;
+3 -6
View File
@@ -2,12 +2,10 @@ 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]">
@@ -27,8 +25,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}
@@ -46,7 +44,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">
@@ -66,7 +64,6 @@ 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
+5 -5
View File
@@ -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
-1
View File
@@ -1 +0,0 @@
Tue, Nov 25, 2025 11:09:55 AM
-2
View File
@@ -1,2 +0,0 @@
# Deployment trigger
# Updated to deploy circular dependency fixes and auth hooks
+1 -1
View File
@@ -3,9 +3,9 @@ import {
authLoginSchema,
TLoginRequest,
usePostLogin,
useAuthStore,
} from '@imphnen-frontend-service/service';
import { zodResolver } from '@hookform/resolvers/zod';
import { useAuthStore } from '@imphnen-frontend-service/utils';
import { toast } from 'sonner';
import { useNavigate } from 'react-router';
import { useVerifyEmail } from './use-verify-email';
-1
View File
@@ -1 +0,0 @@
Tue, Nov 25, 2025 11:09:55 AM
-2
View File
@@ -1,2 +0,0 @@
# Deployment trigger
# Updated to deploy circular dependency fixes and auth hooks
-14
View File
@@ -1,14 +0,0 @@
import nx from '@nx/eslint-plugin';
import baseConfig from '../../eslint.config.mjs';
export default [
...baseConfig,
...nx.configs['flat/react'],
{
files: ['**/*.ts', '**/*.tsx', '**/*.js', '**/*.jsx'],
// Override or add rules here
rules: {
'jsx-a11y/accessible-emoji': 'off',
},
},
];
-16
View File
@@ -1,16 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Hackathon</title>
<base href="/" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" type="image/x-icon" href="/logos/simple.svg" />
<link rel="stylesheet" href="/src/index.css" />
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
-9
View File
@@ -1,9 +0,0 @@
import { join } from 'path';
export default {
plugins: {
'@tailwindcss/postcss': {
base: join(import.meta.dirname, '../../'),
},
},
};
-1
View File
@@ -1 +0,0 @@
{}
-23
View File
@@ -1,23 +0,0 @@
import { Link } from 'react-router-dom';
export default function NotFoundPage() {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="text-center">
<h1 className="text-9xl font-bold text-gray-200 mb-4">404</h1>
<h2 className="text-3xl font-semibold text-gray-900 mb-4">
Page Not Found
</h2>
<p className="text-gray-600 mb-8">
The page you are looking for doesn't exist or has been moved.
</p>
<Link
to="/"
className="inline-block px-6 py-3 bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition-colors"
>
Go Back Home
</Link>
</div>
</div>
);
}
@@ -1,179 +0,0 @@
import { FC, ReactElement, useEffect, useState, useRef } from 'react';
import { useNavigate } from 'react-router';
import { useAuthStore, supabase } from '@imphnen-frontend-service/service';
import { toast } from 'sonner';
const CallbackPage: FC = (): ReactElement => {
const navigate = useNavigate();
const { setSession } = useAuthStore();
const [isProcessing, setIsProcessing] = useState(true);
const [error, setError] = useState<string | null>(null);
const hasRunRef = useRef(false);
useEffect(() => {
const handleCallback = async () => {
if (hasRunRef.current) {
console.log('[Callback] Already processed, skipping...');
return;
}
hasRunRef.current = true;
try {
console.log('[Callback] Processing OAuth callback...');
console.log('[Callback] Current URL:', globalThis.location.href);
// Supabase client is configured with detectSessionInUrl: true
// This means Supabase automatically detects and processes OAuth tokens from the URL hash
// We just need to wait a moment for it to complete, then check for the session
console.log('[Callback] Waiting for Supabase to process OAuth callback...');
await new Promise(resolve => setTimeout(resolve, 1000));
// Get the session that Supabase automatically created from the URL hash
const { data: { session: sessionData }, error: sessionError } = await supabase.auth.getSession();
if (sessionError) {
console.error('[Callback] Session error:', sessionError);
throw new Error(sessionError.message || 'Failed to get session');
}
if (!sessionData || !sessionData.user) {
throw new Error('No session found after OAuth callback. Please try logging in again.');
}
console.log('[Callback] Supabase session established:', {
userId: sessionData.user.id,
email: sessionData.user.email,
});
// Create/update user in the users table (for foreign key constraints)
console.log('[Callback] Creating/updating user record...');
const { data: userData, error: upsertError } = await supabase
.from('users')
.upsert({
id: sessionData.user.id,
email: sessionData.user.email || '',
fullname: sessionData.user.user_metadata?.full_name ||
sessionData.user.user_metadata?.name ||
sessionData.user.email?.split('@')[0] || '',
avatar: sessionData.user.user_metadata?.avatar_url || '',
is_active: true,
updated_at: new Date().toISOString(),
}, {
onConflict: 'id',
})
.select()
.single();
if (upsertError) {
console.warn('[Callback] Failed to create user record:', upsertError);
// Don't throw - continue with login even if user record creation fails
} else {
console.log('[Callback] User record created/updated successfully');
}
// Store user-friendly data in Zustand for UI purposes
// Supabase now manages the actual auth session
// Use data from database if available, otherwise use OAuth metadata
const userRecord = userData || {
id: sessionData.user.id,
email: sessionData.user.email || '',
fullname: sessionData.user.user_metadata?.full_name ||
sessionData.user.user_metadata?.name ||
sessionData.user.email?.split('@')[0] || '',
avatar: sessionData.user.user_metadata?.avatar_url || '',
phone_number: '',
birthdate: '',
gender: '',
is_active: true,
};
setSession({
token: {
access_token: sessionData.access_token,
refresh_token: sessionData.refresh_token || '',
},
user: {
id: userRecord.id,
email: userRecord.email,
fullname: userRecord.fullname,
phone_number: userRecord.phone_number || '',
avatar: userRecord.avatar || '',
birthdate: userRecord.birthdate || '',
gender: userRecord.gender || '',
is_active: userRecord.is_active,
location: userRecord.location,
bio: userRecord.bio,
skills: userRecord.skills,
role: {
id: '',
name: 'user',
permissions: [],
created_at: '',
updated_at: '',
},
},
});
console.log('[Callback] Session stored successfully');
toast.success('Login successful!');
setIsProcessing(false);
// Check if user has completed onboarding (has location)
// Use globalThis.location.replace for hard redirect to prevent history issues
if (userRecord.location) {
console.log('[Callback] User has completed onboarding, redirecting to dashboard...');
globalThis.location.replace('/dashboard');
} else {
console.log('[Callback] User needs onboarding, redirecting...');
globalThis.location.replace('/onboarding/user');
}
} catch (err) {
console.error('[Callback] Error:', err);
setError((err as Error).message);
setIsProcessing(false);
toast.error('An error occurred during login');
setTimeout(() => {
navigate('/auth/login');
}, 3000);
}
};
handleCallback();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []); // Run only once on mount
if (error) {
return (
<div className="flex justify-center items-center min-h-screen bg-gray-50 px-4">
<div className="bg-white w-full max-w-2xl p-8 rounded-2xl shadow-lg border border-red-200">
<div className="text-center mb-6">
<div className="text-red-500 text-5xl mb-4"></div>
<h2 className="text-2xl font-bold text-gray-900 mb-2">
GitHub Login Failed
</h2>
<p className="text-red-600 mb-4 whitespace-pre-line">{error}</p>
</div>
<p className="text-gray-600 text-sm mt-6 text-center">
Redirecting to login page in 3 seconds...
</p>
</div>
</div>
);
}
return (
<div className="flex justify-center items-center min-h-screen bg-gray-50">
<div className="text-center">
<div className="inline-block animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600 mb-4"></div>
<h2 className="text-xl font-semibold text-gray-900 mb-2">
Completing login...
</h2>
<p className="text-gray-600">Please wait</p>
</div>
</div>
);
};
export default CallbackPage;
@@ -1,125 +0,0 @@
import { useState } from 'react';
import { supabase } from '@imphnen-frontend-service/service';
import { Link } from 'react-router';
import { toast } from 'sonner';
export default function ForgotPasswordPage() {
const [email, setEmail] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [emailSent, setEmailSent] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!email) {
toast.error('Please enter your email');
return;
}
try {
setIsLoading(true);
const { error } = await supabase.auth.resetPasswordForEmail(email, {
redirectTo: `${window.location.origin}/auth/reset-password`,
});
if (error) {
throw error;
}
setEmailSent(true);
toast.success('Password reset email sent! Check your inbox.');
} catch (err) {
console.error('Failed to send reset email:', err);
toast.error((err as Error).message || 'Failed to send reset email');
} finally {
setIsLoading(false);
}
};
if (emailSent) {
return (
<div className="flex justify-center items-center min-h-screen bg-gray-50 p-4">
<div className="bg-white w-full max-w-md p-8 rounded-2xl shadow-lg border border-gray-200 text-center">
<div className="mb-6">
<div className="mx-auto w-16 h-16 bg-green-100 rounded-full flex items-center justify-center mb-4">
<span className="text-3xl"></span>
</div>
<h2 className="text-3xl font-bold text-gray-900 mb-2">
Check Your Email
</h2>
<p className="text-gray-600">
We've sent a password reset link to <strong>{email}</strong>
</p>
</div>
<div className="space-y-4">
<p className="text-sm text-gray-600">
Click the link in the email to reset your password. The link will expire in 1 hour.
</p>
<Link to="/auth/login">
<button className="w-full py-3 bg-primary-600 text-white rounded-lg font-semibold hover:bg-primary-700 transition-colors">
Back to Login
</button>
</Link>
<button
onClick={() => setEmailSent(false)}
className="w-full py-3 text-gray-600 hover:text-gray-900 transition-colors"
>
Send another email
</button>
</div>
</div>
</div>
);
}
return (
<div className="flex justify-center items-center min-h-screen bg-gray-50 p-4">
<div className="bg-white w-full max-w-md p-8 rounded-2xl shadow-lg border border-gray-200">
<div className="text-center mb-8">
<h2 className="text-3xl font-bold text-gray-900 mb-2">
Forgot Password?
</h2>
<p className="text-gray-600">
No worries, we'll send you reset instructions
</p>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label htmlFor="email" className="block text-sm font-medium text-gray-700 mb-1">
Email Address
</label>
<input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="your@email.com"
disabled={isLoading}
className="w-full px-4 py-2.5 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent disabled:bg-gray-100 disabled:cursor-not-allowed"
required
/>
</div>
<button
type="submit"
disabled={isLoading}
className="w-full py-3 bg-primary-600 text-white rounded-lg font-semibold hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 disabled:bg-gray-400 disabled:cursor-not-allowed transition-colors"
>
{isLoading ? 'Sending...' : 'Send Reset Link'}
</button>
</form>
<div className="mt-6 text-center">
<Link to="/auth/login" className="text-primary-600 hover:text-primary-700 font-semibold">
Back to Login
</Link>
</div>
</div>
</div>
);
}
@@ -1,89 +0,0 @@
import { FC, ReactElement, useEffect, useState } from 'react';
import { Button } from '@imphnen-frontend-service/ui/atoms';
import { useNavigate } from 'react-router';
import { useGitHubAuth } from '@imphnen-frontend-service/service';
import { useSession } from '@imphnen-frontend-service/utils';
import { GithubOutlined } from '@ant-design/icons';
const LoginPage: FC = (): ReactElement => {
console.log('[LoginPage] Rendering...');
const navigate = useNavigate();
const { signInWithGitHub } = useGitHubAuth();
const [isGithubLoading, setIsGithubLoading] = useState(false);
const { isAuthenticated } = useSession();
console.log('[LoginPage] isAuthenticated:', isAuthenticated);
useEffect(() => {
console.log('[LoginPage] useEffect - isAuthenticated:', isAuthenticated);
if (isAuthenticated) {
console.log('[LoginPage] Redirecting to dashboard...');
navigate('/dashboard');
}
}, [isAuthenticated, navigate]);
const handleGithubLogin = async () => {
try {
setIsGithubLoading(true);
console.log('[Login] Initiating GitHub OAuth...');
const result = await signInWithGitHub();
console.log('[Login] OAuth result:', result);
// Check if we got a redirect URL
if (result?.url) {
console.log('[Login] Redirecting to GitHub OAuth:', result.url);
// Supabase should handle the redirect automatically
// If we're still here after 2 seconds, manually redirect
setTimeout(() => {
if (result.url) {
globalThis.location.href = result.url;
}
}, 2000);
} else {
console.error('[Login] No OAuth URL returned');
setIsGithubLoading(false);
}
} catch (error) {
console.error('[Login] GitHub login failed:', error);
setIsGithubLoading(false);
}
};
return (
<div className="flex justify-center items-center min-h-screen bg-gray-50 px-4">
<div className="bg-white w-full max-w-md p-8 rounded-2xl shadow-lg border border-gray-100">
<div className="text-center mb-8">
<h2 className="text-3xl font-bold text-gray-900 mb-2">
Welcome to Hackathon
</h2>
<p className="text-gray-600">
Sign in with your GitHub account to join or create your hackathon team
</p>
</div>
<Button
className="w-full gap-2 py-3"
variant="secondary"
onClick={handleGithubLogin}
disabled={isGithubLoading}
type="button"
>
<GithubOutlined className="text-xl" />
<span className="font-semibold">
{isGithubLoading ? 'Connecting...' : 'Sign in with GitHub'}
</span>
</Button>
<div className="mt-6 text-center">
<p className="text-gray-500 text-sm">
By signing in, you agree to our Terms of Service and Privacy Policy
</p>
</div>
</div>
</div>
);
};
export default LoginPage;
-209
View File
@@ -1,209 +0,0 @@
import { useState } from 'react';
import { useGitHubAuth, useEmailAuth, supabase, useAuthStore } from '@imphnen-frontend-service/service';
import { GithubOutlined } from '@ant-design/icons';
import { useNavigate, Link } from 'react-router';
import { toast } from 'sonner';
export default function LoginPage() {
console.log('[LoginPage] Rendering...');
const navigate = useNavigate();
const { setSession } = useAuthStore();
const { signInWithGitHub } = useGitHubAuth();
const { signInWithEmail } = useEmailAuth();
const [isGithubLoading, setIsGithubLoading] = useState(false);
const [isEmailLoading, setIsEmailLoading] = useState(false);
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState<string | null>(null);
const handleEmailLogin = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
if (!email || !password) {
setError('Please enter both email and password');
return;
}
try {
setIsEmailLoading(true);
console.log('[Login] Attempting email login...');
const result = await signInWithEmail(email, password);
console.log('[Login] Email login successful:', result);
// Get user data from database
const { data: userData } = await supabase
.from('users')
.select('*')
.eq('id', result.user.id)
.single();
// Store session in Zustand
setSession({
token: {
access_token: result.session.access_token,
refresh_token: result.session.refresh_token || '',
},
user: {
id: result.user.id,
email: result.user.email || '',
fullname: userData?.fullname || result.user.user_metadata?.full_name || '',
phone_number: userData?.phone_number || '',
avatar: userData?.avatar || '',
birthdate: userData?.birthdate || '',
gender: userData?.gender || '',
is_active: userData?.is_active || true,
location: userData?.location,
bio: userData?.bio,
skills: userData?.skills,
role: {
id: '',
name: 'user',
permissions: [],
created_at: '',
updated_at: '',
},
},
});
toast.success('Login successful!');
// Redirect based on onboarding status
if (userData?.location) {
navigate('/dashboard');
} else {
navigate('/onboarding/user');
}
} catch (err) {
console.error('[Login] Email login failed:', err);
setError((err as Error).message || 'Login failed');
setIsEmailLoading(false);
}
};
const handleGithubLogin = async () => {
try {
setIsGithubLoading(true);
console.log('[Login] Initiating GitHub OAuth...');
const result = await signInWithGitHub();
console.log('[Login] OAuth result:', result);
// Check if we got a redirect URL
if (result?.url) {
console.log('[Login] Redirecting to GitHub OAuth:', result.url);
// Manually redirect immediately
globalThis.location.href = result.url;
} else {
console.error('[Login] No OAuth URL returned');
setIsGithubLoading(false);
}
} catch (error) {
console.error('[Login] GitHub login failed:', error);
setIsGithubLoading(false);
}
};
return (
<div className="flex justify-center items-center min-h-screen bg-gray-50 p-4">
<div className="bg-white w-full max-w-md p-8 rounded-2xl shadow-lg border border-gray-200">
<div className="text-center mb-8">
<h2 className="text-3xl font-bold text-gray-900 mb-2">
Welcome Back
</h2>
<p className="text-gray-600">
Sign in to join or create your hackathon team
</p>
</div>
{error && (
<div className="mb-6 p-3 bg-red-50 border border-red-200 rounded-lg">
<p className="text-red-600 text-sm">{error}</p>
</div>
)}
<form onSubmit={handleEmailLogin} className="space-y-4">
<div>
<label htmlFor="email" className="block text-sm font-medium text-gray-700 mb-1">
Email
</label>
<input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="your@email.com"
disabled={isEmailLoading}
className="w-full px-4 py-2.5 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent disabled:bg-gray-100 disabled:cursor-not-allowed"
required
/>
</div>
<div>
<div className="flex items-center justify-between mb-1">
<label htmlFor="password" className="block text-sm font-medium text-gray-700">
Password
</label>
<Link to="/auth/forgot-password" className="text-sm text-primary-600 hover:text-primary-700">
Forgot password?
</Link>
</div>
<input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="••••••••"
disabled={isEmailLoading}
className="w-full px-4 py-2.5 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent disabled:bg-gray-100 disabled:cursor-not-allowed"
required
/>
</div>
<button
type="submit"
disabled={isEmailLoading}
className="w-full py-3 bg-primary-600 text-white rounded-lg font-semibold hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 disabled:bg-gray-400 disabled:cursor-not-allowed transition-colors"
>
{isEmailLoading ? 'Signing in...' : 'Sign in with Email'}
</button>
</form>
<div className="my-6 flex items-center">
<div className="flex-1 border-t border-gray-300"></div>
<span className="px-4 text-sm text-gray-500">OR</span>
<div className="flex-1 border-t border-gray-300"></div>
</div>
<button
onClick={handleGithubLogin}
disabled={isGithubLoading}
type="button"
className="w-full py-3 flex items-center justify-center gap-2 bg-gray-100 border border-gray-300 rounded-lg font-semibold hover:bg-gray-200 focus:outline-none focus:ring-2 focus:ring-gray-400 focus:ring-offset-2 disabled:bg-gray-100 disabled:cursor-not-allowed transition-colors"
>
<GithubOutlined className="text-xl" />
<span>
{isGithubLoading ? 'Connecting...' : 'Sign in with GitHub'}
</span>
</button>
<div className="mt-6 text-center">
<p className="text-gray-600 text-sm">
Don't have an account?{' '}
<a href="/auth/signup" className="text-primary-600 hover:text-primary-700 font-semibold">
Sign up
</a>
</p>
</div>
<div className="mt-6 text-center">
<p className="text-gray-500 text-xs">
By signing in, you agree to our Terms of Service and Privacy Policy
</p>
</div>
</div>
</div>
);
}
@@ -1,131 +0,0 @@
import { useState, useEffect } from 'react';
import { supabase } from '@imphnen-frontend-service/service';
import { useNavigate } from 'react-router';
import { toast } from 'sonner';
export default function ResetPasswordPage() {
const navigate = useNavigate();
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [isValidToken, setIsValidToken] = useState(false);
useEffect(() => {
// Check if we have a valid session (from the reset link)
supabase.auth.getSession().then(({ data: { session } }) => {
if (session) {
setIsValidToken(true);
} else {
toast.error('Invalid or expired reset link');
setTimeout(() => navigate('/auth/forgot-password'), 2000);
}
});
}, [navigate]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (password !== confirmPassword) {
toast.error('Passwords do not match');
return;
}
if (password.length < 6) {
toast.error('Password must be at least 6 characters');
return;
}
try {
setIsLoading(true);
const { error } = await supabase.auth.updateUser({
password: password
});
if (error) {
throw error;
}
toast.success('Password updated successfully!');
// Sign out and redirect to login
await supabase.auth.signOut();
navigate('/auth/login');
} catch (err) {
console.error('Failed to reset password:', err);
toast.error((err as Error).message || 'Failed to reset password');
} finally {
setIsLoading(false);
}
};
if (!isValidToken) {
return (
<div className="flex justify-center items-center min-h-screen bg-gray-50">
<div className="text-center">
<div className="inline-block animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600 mb-4"></div>
<p className="text-gray-600">Verifying reset link...</p>
</div>
</div>
);
}
return (
<div className="flex justify-center items-center min-h-screen bg-gray-50 p-4">
<div className="bg-white w-full max-w-md p-8 rounded-2xl shadow-lg border border-gray-200">
<div className="text-center mb-8">
<h2 className="text-3xl font-bold text-gray-900 mb-2">
Set New Password
</h2>
<p className="text-gray-600">
Enter your new password below
</p>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label htmlFor="password" className="block text-sm font-medium text-gray-700 mb-1">
New Password
</label>
<input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="••••••••"
disabled={isLoading}
className="w-full px-4 py-2.5 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent disabled:bg-gray-100 disabled:cursor-not-allowed"
required
minLength={6}
/>
</div>
<div>
<label htmlFor="confirmPassword" className="block text-sm font-medium text-gray-700 mb-1">
Confirm New Password
</label>
<input
id="confirmPassword"
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
placeholder="••••••••"
disabled={isLoading}
className="w-full px-4 py-2.5 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent disabled:bg-gray-100 disabled:cursor-not-allowed"
required
minLength={6}
/>
</div>
<button
type="submit"
disabled={isLoading}
className="w-full py-3 bg-primary-600 text-white rounded-lg font-semibold hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 disabled:bg-gray-400 disabled:cursor-not-allowed transition-colors"
>
{isLoading ? 'Updating...' : 'Update Password'}
</button>
</form>
</div>
</div>
);
}
-257
View File
@@ -1,257 +0,0 @@
import { useState } from 'react';
import { useGitHubAuth, useEmailAuth, supabase, useAuthStore } from '@imphnen-frontend-service/service';
import { GithubOutlined } from '@ant-design/icons';
import { useNavigate } from 'react-router';
import { toast } from 'sonner';
export default function SignupPage() {
const navigate = useNavigate();
const { setSession } = useAuthStore();
const { signInWithGitHub } = useGitHubAuth();
const { signUpWithEmail } = useEmailAuth();
const [isGithubLoading, setIsGithubLoading] = useState(false);
const [isEmailLoading, setIsEmailLoading] = useState(false);
const [fullname, setFullname] = useState('');
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [error, setError] = useState<string | null>(null);
const handleEmailSignup = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
if (!fullname || !email || !password || !confirmPassword) {
setError('Please fill in all fields');
return;
}
if (password !== confirmPassword) {
setError('Passwords do not match');
return;
}
if (password.length < 6) {
setError('Password must be at least 6 characters long');
return;
}
try {
setIsEmailLoading(true);
console.log('[Signup] Attempting email signup...');
const result = await signUpWithEmail(email, password, fullname);
console.log('[Signup] Email signup successful:', result);
if (!result.user) {
throw new Error('Signup failed - no user returned');
}
// Create user record in database
const { error: upsertError } = await supabase
.from('users')
.upsert({
id: result.user.id,
email: result.user.email || '',
fullname: fullname,
is_active: true,
updated_at: new Date().toISOString(),
}, {
onConflict: 'id',
});
if (upsertError) {
console.warn('[Signup] Failed to create user record:', upsertError);
}
// If session is available (email confirmation disabled), store it
if (result.session) {
setSession({
token: {
access_token: result.session.access_token,
refresh_token: result.session.refresh_token || '',
},
user: {
id: result.user.id,
email: result.user.email || '',
fullname: fullname,
phone_number: '',
avatar: '',
birthdate: '',
gender: '',
is_active: true,
role: {
id: '',
name: 'user',
permissions: [],
created_at: '',
updated_at: '',
},
},
});
toast.success('Account created successfully!');
navigate('/onboarding/user');
} else {
// Email confirmation is enabled
toast.success('Account created! Please check your email to verify your account.');
setTimeout(() => {
navigate('/auth/login');
}, 2000);
}
} catch (err) {
console.error('[Signup] Email signup failed:', err);
setError((err as Error).message || 'Signup failed');
setIsEmailLoading(false);
}
};
const handleGithubLogin = async () => {
try {
setIsGithubLoading(true);
console.log('[Signup] Initiating GitHub OAuth...');
const result = await signInWithGitHub();
console.log('[Signup] OAuth result:', result);
if (result?.url) {
console.log('[Signup] Redirecting to GitHub OAuth:', result.url);
globalThis.location.href = result.url;
} else {
console.error('[Signup] No OAuth URL returned');
setIsGithubLoading(false);
}
} catch (error) {
console.error('[Signup] GitHub login failed:', error);
setIsGithubLoading(false);
}
};
return (
<div className="flex justify-center items-center min-h-screen bg-gray-50 p-4">
<div className="bg-white w-full max-w-md p-8 rounded-2xl shadow-lg border border-gray-200">
<div className="text-center mb-8">
<h2 className="text-3xl font-bold text-gray-900 mb-2">
Create Account
</h2>
<p className="text-gray-600">
Join the hackathon community
</p>
</div>
{error && (
<div className="mb-6 p-3 bg-red-50 border border-red-200 rounded-lg">
<p className="text-red-600 text-sm">{error}</p>
</div>
)}
<form onSubmit={handleEmailSignup} className="space-y-4">
<div>
<label htmlFor="fullname" className="block text-sm font-medium text-gray-700 mb-1">
Full Name
</label>
<input
id="fullname"
type="text"
value={fullname}
onChange={(e) => setFullname(e.target.value)}
placeholder="John Doe"
disabled={isEmailLoading}
className="w-full px-4 py-2.5 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent disabled:bg-gray-100 disabled:cursor-not-allowed"
required
/>
</div>
<div>
<label htmlFor="email" className="block text-sm font-medium text-gray-700 mb-1">
Email
</label>
<input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="your@email.com"
disabled={isEmailLoading}
className="w-full px-4 py-2.5 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent disabled:bg-gray-100 disabled:cursor-not-allowed"
required
/>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium text-gray-700 mb-1">
Password
</label>
<input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="••••••••"
disabled={isEmailLoading}
className="w-full px-4 py-2.5 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent disabled:bg-gray-100 disabled:cursor-not-allowed"
required
/>
</div>
<div>
<label htmlFor="confirmPassword" className="block text-sm font-medium text-gray-700 mb-1">
Confirm Password
</label>
<input
id="confirmPassword"
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
placeholder="••••••••"
disabled={isEmailLoading}
className="w-full px-4 py-2.5 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent disabled:bg-gray-100 disabled:cursor-not-allowed"
required
/>
</div>
<button
type="submit"
disabled={isEmailLoading}
className="w-full py-3 bg-primary-600 text-white rounded-lg font-semibold hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 disabled:bg-gray-400 disabled:cursor-not-allowed transition-colors"
>
{isEmailLoading ? 'Creating account...' : 'Create Account'}
</button>
</form>
<div className="my-6 flex items-center">
<div className="flex-1 border-t border-gray-300"></div>
<span className="px-4 text-sm text-gray-500">OR</span>
<div className="flex-1 border-t border-gray-300"></div>
</div>
<button
onClick={handleGithubLogin}
disabled={isGithubLoading}
type="button"
className="w-full py-3 flex items-center justify-center gap-2 bg-gray-100 border border-gray-300 rounded-lg font-semibold hover:bg-gray-200 focus:outline-none focus:ring-2 focus:ring-gray-400 focus:ring-offset-2 disabled:bg-gray-100 disabled:cursor-not-allowed transition-colors"
>
<GithubOutlined className="text-xl" />
<span>
{isGithubLoading ? 'Connecting...' : 'Sign up with GitHub'}
</span>
</button>
<div className="mt-6 text-center">
<p className="text-gray-600 text-sm">
Already have an account?{' '}
<a href="/auth/login" className="text-primary-600 hover:text-primary-700 font-semibold">
Sign in
</a>
</p>
</div>
<div className="mt-6 text-center">
<p className="text-gray-500 text-xs">
By signing up, you agree to our Terms of Service and Privacy Policy
</p>
</div>
</div>
</div>
);
}
-282
View File
@@ -1,282 +0,0 @@
import { FC, ReactElement } from 'react';
import { Button } from '@imphnen-frontend-service/ui/atoms';
import { Link, useNavigate } from 'react-router';
import { useMyTeams, useMyInvitations, useRespondToInvitation, supabase, useAuthStore } from '@imphnen-frontend-service/service';
import { toast } from 'sonner';
const DashboardPage: FC = (): ReactElement => {
const navigate = useNavigate();
const { session, clearSession } = useAuthStore();
const { data: teamsData } = useMyTeams();
const { data: invitationsData } = useMyInvitations();
const { mutateAsync: respondToInvitation } = useRespondToInvitation();
const user = session?.user;
const myTeams = teamsData?.data || [];
const invitations = invitationsData?.data || [];
const handleAcceptInvitation = async (invitationId: string) => {
try {
await respondToInvitation({ invitationId, action: 'accept' });
toast.success('Invitation accepted! You are now a team member.');
} catch (error) {
console.error('Failed to accept invitation:', error);
toast.error('Failed to accept invitation');
}
};
const handleRejectInvitation = async (invitationId: string) => {
try {
await respondToInvitation({ invitationId, action: 'reject' });
toast.success('Invitation declined');
} catch (error) {
console.error('Failed to reject invitation:', error);
toast.error('Failed to decline invitation');
}
};
const handleLogout = async () => {
try {
// Clear Supabase session
await supabase.auth.signOut();
// Clear Zustand store
clearSession();
// Clear localStorage
localStorage.clear();
toast.success('Logged out successfully');
// Redirect to login
navigate('/auth/login');
} catch (error) {
console.error('Logout error:', error);
// Even if there's an error, clear everything and redirect
clearSession();
localStorage.clear();
navigate('/auth/login');
}
};
return (
<div className="min-h-screen bg-gray-50">
{/* Header */}
<div className="bg-white border-b">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold text-gray-900">
Welcome, {user?.fullname || 'User'}!
</h1>
<p className="text-gray-600 mt-1">
{user?.location && `📍 ${user.location}`}
</p>
</div>
<div className="flex items-center space-x-3">
{user?.avatar && (
<img
src={user.avatar}
alt="Profile"
className="w-16 h-16 rounded-full object-cover border-2 border-gray-200"
/>
)}
<Button
onClick={handleLogout}
variant="secondary"
className="bg-red-500 hover:bg-red-600 text-white"
>
Logout
</Button>
</div>
</div>
</div>
</div>
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{/* Team Invitations */}
{invitations.length > 0 && (
<div className="mb-8 bg-blue-50 border border-blue-200 rounded-lg p-6">
<h2 className="text-xl font-bold text-gray-900 mb-4">
Team Invitations ({invitations.length})
</h2>
<div className="space-y-3">
{invitations.map((invitation) => (
<div key={invitation.id} className="bg-white p-4 rounded-lg shadow-sm flex items-center justify-between">
<div>
<p className="font-medium text-gray-900">{invitation.team.name}</p>
<p className="text-sm text-gray-600">
Invited by {invitation.inviter.fullname}
</p>
</div>
<div className="flex space-x-2">
<Button
size="sm"
onClick={() => handleAcceptInvitation(invitation.id)}
>
Accept
</Button>
<Button
size="sm"
variant="secondary"
onClick={() => handleRejectInvitation(invitation.id)}
>
Decline
</Button>
</div>
</div>
))}
</div>
</div>
)}
{/* My Teams */}
{myTeams.length > 0 ? (
<div className="mb-8">
<h2 className="text-2xl font-bold text-gray-900 mb-4">My Team</h2>
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
{myTeams.map((team) => (
<Link
key={team.id}
to={`/teams/${team.id}`}
className="bg-white rounded-lg shadow-md overflow-hidden hover:shadow-lg transition-shadow"
>
{team.banner && (
<img
src={team.banner}
alt={team.name}
className="w-full h-32 object-cover"
/>
)}
<div className="p-6">
<div className="flex items-center space-x-3 mb-3">
{team.logo && (
<img
src={team.logo}
alt={team.name}
className="w-12 h-12 rounded-full object-cover"
/>
)}
<div>
<h3 className="text-lg font-bold text-gray-900">{team.name}</h3>
<p className="text-sm text-gray-600">📍 {team.city}</p>
</div>
</div>
<p className="text-gray-600 text-sm line-clamp-2">
{team.description}
</p>
</div>
</Link>
))}
</div>
</div>
) : (
/* No Team - Show CTAs */
<div className="bg-white rounded-lg shadow-md p-8">
<div className="text-center mb-8">
<h2 className="text-2xl font-bold text-gray-900 mb-2">
You're not in a team yet
</h2>
<p className="text-gray-600">
Join an existing team or create your own to get started
</p>
</div>
<div className="grid gap-6 md:grid-cols-2 max-w-2xl mx-auto">
<Link
to="/teams/browse"
className="bg-blue-600 text-white rounded-lg p-6 hover:bg-blue-700 transition-colors text-center"
>
<div className="text-4xl mb-3">🔍</div>
<h3 className="text-xl font-bold mb-2">Browse Teams</h3>
<p className="text-blue-100">
Find and join existing teams looking for members
</p>
</Link>
<Link
to="/teams/create"
className="bg-green-600 text-white rounded-lg p-6 hover:bg-green-700 transition-colors text-center"
>
<div className="text-4xl mb-3"></div>
<h3 className="text-xl font-bold mb-2">Create Team</h3>
<p className="text-green-100">
Start your own team and invite members
</p>
</Link>
</div>
</div>
)}
{/* User Profile Card */}
<div className="mt-8 bg-gradient-to-br from-blue-50 to-indigo-50 rounded-xl shadow-lg overflow-hidden">
<div className="bg-gradient-to-r from-blue-600 to-indigo-600 h-24"></div>
<div className="px-8 pb-8">
<div className="flex items-start -mt-12 mb-6">
{user?.avatar && (
<img
src={user.avatar}
alt={user.fullname}
className="w-24 h-24 rounded-full border-4 border-white shadow-lg object-cover"
/>
)}
<div className="ml-6 mt-14">
<h2 className="text-2xl font-bold text-gray-900">{user?.fullname}</h2>
{user?.location ? (
<p className="text-gray-600 flex items-center mt-1">
<svg className="w-4 h-4 mr-1" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" clipRule="evenodd" />
</svg>
{user.location}
</p>
) : (
<Link to="/onboarding/user" className="text-blue-600 hover:text-blue-700 text-sm mt-1 inline-block">
Complete your profile
</Link>
)}
</div>
</div>
<div className="space-y-6">
{user?.bio && (
<div className="bg-white rounded-lg p-4 shadow-sm">
<h3 className="text-sm font-semibold text-gray-700 uppercase tracking-wide mb-2">About</h3>
<p className="text-gray-800 leading-relaxed">{user.bio}</p>
</div>
)}
<div className="bg-white rounded-lg p-4 shadow-sm">
<h3 className="text-sm font-semibold text-gray-700 uppercase tracking-wide mb-3">Contact</h3>
<div className="flex items-center text-gray-700">
<svg className="w-5 h-5 mr-3 text-gray-400" fill="currentColor" viewBox="0 0 20 20">
<path d="M2.003 5.884L10 9.882l7.997-3.998A2 2 0 0016 4H4a2 2 0 00-1.997 1.884z" />
<path d="M18 8.118l-8 4-8-4V14a2 2 0 002 2h12a2 2 0 002-2V8.118z" />
</svg>
<span className="text-gray-900">{user?.email}</span>
</div>
</div>
{user?.skills && user.skills.length > 0 && (
<div className="bg-white rounded-lg p-4 shadow-sm">
<h3 className="text-sm font-semibold text-gray-700 uppercase tracking-wide mb-3">Skills</h3>
<div className="flex flex-wrap gap-3">
{user.skills.map((skill: string) => (
<span
key={skill}
className="inline-flex items-center px-4 py-2 bg-blue-600 text-white rounded-lg text-sm font-medium shadow-sm hover:bg-blue-700 transition-colors"
>
{skill}
</span>
))}
</div>
</div>
)}
</div>
</div>
</div>
</div>
</div>
);
};
export default DashboardPage;
-29
View File
@@ -1,29 +0,0 @@
import { useRouteError, isRouteErrorResponse } from 'react-router-dom';
export default function ErrorPage() {
const error = useRouteError();
let errorMessage: string;
if (isRouteErrorResponse(error)) {
errorMessage = error.statusText;
} else if (error instanceof Error) {
errorMessage = error.message;
} else if (typeof error === 'string') {
errorMessage = error;
} else {
console.error(error);
errorMessage = 'Unknown error';
}
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="text-center">
<h1 className="text-6xl font-bold text-red-600 mb-4">Oops!</h1>
<p className="text-xl text-gray-700 mb-2">
Sorry, an unexpected error has occurred.
</p>
<p className="text-gray-500 italic">{errorMessage}</p>
</div>
</div>
);
}
-125
View File
@@ -1,125 +0,0 @@
import { Outlet, ScrollRestoration, useLocation, useNavigate } from 'react-router-dom';
import { useEffect, useState } from 'react';
import { supabase } from '@imphnen-frontend-service/service';
// Define onboarding routes
const ONBOARDING_ROUTES = new Set(['/onboarding/user']);
export default function RootLayout() {
const location = useLocation();
const navigate = useNavigate();
const [isChecking, setIsChecking] = useState(true);
useEffect(() => {
const checkAuth = async () => {
const pathname = location.pathname;
console.log('[Layout] Checking auth for route:', pathname);
// Allow hackathon pages without checks
if (pathname.startsWith('/hackathons')) {
console.log('[Layout] Public route, allowing access');
setIsChecking(false);
return;
}
// Allow auth callback without checks
if (pathname === '/auth/callback') {
console.log('[Layout] Auth callback, allowing access');
setIsChecking(false);
return;
}
// Check Supabase session
const { data: { session }, error } = await supabase.auth.getSession();
if (error) {
console.error('[Layout] Session error:', error);
}
// Public auth pages (login, signup, forgot-password, reset-password) - allow unauthenticated access
const isPublicAuthPage = pathname === '/auth/login' || pathname === '/auth/signup' || pathname === '/auth/forgot-password' || pathname === '/auth/reset-password';
if (isPublicAuthPage) {
// If already authenticated and not on password reset pages, redirect to dashboard
if (session && pathname !== '/auth/reset-password') {
console.log('[Layout] Already authenticated, redirecting to dashboard');
navigate('/dashboard', { replace: true });
setIsChecking(false);
return;
}
// Allow unauthenticated access
console.log('[Layout] Public auth page, allowing access');
setIsChecking(false);
return;
}
// Home page - allow everyone to view the landing page
if (pathname === '/') {
console.log('[Layout] Landing page, allowing access');
setIsChecking(false);
return;
}
// Require authentication for all other routes
if (!session) {
console.log('[Layout] No session, redirecting to login');
navigate('/auth/login', { replace: true });
setIsChecking(false);
return;
}
// Check if user has completed onboarding (skip for onboarding routes)
if (!ONBOARDING_ROUTES.has(pathname)) {
try {
const { data: userData, error: userError } = await supabase
.from('users')
.select('location')
.eq('id', session.user.id)
.single();
if (userError) {
console.error('[Layout] Failed to fetch user data:', userError);
setIsChecking(false);
return;
}
const hasLocation = !!userData?.location;
if (!hasLocation) {
console.log('[Layout] User needs onboarding, redirecting');
navigate('/onboarding/user', { replace: true });
setIsChecking(false);
return;
}
} catch (error) {
console.error('[Layout] Unexpected error checking onboarding:', error);
}
}
console.log('[Layout] Auth check passed');
setIsChecking(false);
};
checkAuth();
}, [location.pathname, navigate]);
// Show loading state while checking auth
if (isChecking) {
return (
<div className="flex justify-center items-center min-h-screen bg-gray-50">
<div className="text-center">
<div className="inline-block animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600 mb-4"></div>
<p className="text-gray-600">Loading...</p>
</div>
</div>
);
}
return (
<>
<Outlet />
<ScrollRestoration />
</>
);
}
@@ -1,272 +0,0 @@
import { FC, ReactElement, useState, useEffect } from 'react';
import { ControlledInputField } from '@imphnen-frontend-service/ui/organisms';
import { Button, Textarea } from '@imphnen-frontend-service/ui/atoms';
import { useNavigate } from 'react-router';
import { useForm, Controller } from 'react-hook-form';
import {
userOnboardingSchema,
TUserOnboardingForm,
useUpdateUserMe,
useUploadAvatar,
useUserMe,
useAuthStore,
} from '@imphnen-frontend-service/service';
import { zodResolver } from '@hookform/resolvers/zod';
import INDONESIAN_CITIES from '../../../constants/cities';
const ROLE_OPTIONS = [
'Frontend Developer',
'Backend Developer',
'Full Stack Developer',
'DevOps Engineer',
'UI/UX Designer',
'Product Manager',
'Data Scientist',
'Mobile Developer',
];
const UserOnboardingPage: FC = (): ReactElement => {
const navigate = useNavigate();
const [avatarFile, setAvatarFile] = useState<File | null>(null);
const [avatarPreview, setAvatarPreview] = useState<string>('');
const { data: userData } = useUserMe();
const { mutateAsync: updateUser, isPending: isUpdating } = useUpdateUserMe();
const { mutateAsync: uploadAvatar, isPending: isUploading } =
useUploadAvatar();
const { session } = useAuthStore();
const form = useForm<TUserOnboardingForm>({
resolver: zodResolver(userOnboardingSchema),
mode: 'all',
defaultValues: {
fullname: session?.user?.fullname || userData?.data?.fullname || '',
location: session?.user?.location || '',
bio: session?.user?.bio || '',
skills: session?.user?.skills || [],
},
});
// Set initial avatar preview from GitHub avatar if available
useEffect(() => {
if (session?.user?.avatar && !avatarPreview) {
setAvatarPreview(session.user.avatar);
}
}, [session?.user?.avatar, avatarPreview]);
const handleAvatarChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
setAvatarFile(file);
const reader = new FileReader();
reader.onloadend = () => {
setAvatarPreview(reader.result as string);
};
reader.readAsDataURL(file);
}
};
const onSubmit = form.handleSubmit(async (data) => {
try {
console.log('[Onboarding] Starting submission...', data);
let avatarUrl = session?.user?.avatar || null;
// Upload avatar if a new file was selected
if (avatarFile) {
console.log('[Onboarding] Uploading avatar...');
const uploadResult = await uploadAvatar(avatarFile);
avatarUrl = uploadResult.data.url;
console.log('[Onboarding] Avatar uploaded:', avatarUrl);
}
// Update user in Supabase
console.log('[Onboarding] Updating user in Supabase...');
const result = await updateUser({
fullname: data.fullname,
avatar: avatarUrl,
location: data.location,
bio: data.bio,
skills: data.skills,
});
console.log('[Onboarding] User updated successfully:', result);
// Wait a bit for the onSuccess handler to update localStorage
// The updateUser mutation's onSuccess handler updates the Zustand store and localStorage
await new Promise((resolve) => setTimeout(resolve, 100));
console.log('[Onboarding] Navigating to dashboard...');
// Use window.location for a full page reload to ensure middleware sees updated localStorage
globalThis.location.href = '/dashboard';
} catch (error) {
console.error('[Onboarding] Onboarding failed:', error);
alert(
`Onboarding failed: ${
error instanceof Error ? error.message : 'Unknown error'
}`
);
}
});
return (
<div className="flex flex-col justify-center items-center min-h-screen bg-gray-50 px-4 py-8">
<div className="bg-white w-full max-w-2xl p-8 rounded-xl shadow-lg">
<div className="mb-6">
<h1 className="text-3xl font-bold text-gray-900 mb-2">
Complete Your Profile
</h1>
<p className="text-gray-600">
Tell us more about yourself to get started
</p>
</div>
<form onSubmit={onSubmit} className="space-y-6">
{/* Avatar Upload */}
<div className="flex flex-col items-center space-y-4">
<div className="relative">
{avatarPreview ? (
<img
src={avatarPreview}
alt="Avatar preview"
className="w-32 h-32 rounded-full object-cover border-4 border-gray-200"
/>
) : (
<div className="w-32 h-32 rounded-full bg-gray-200 flex items-center justify-center">
<span className="text-gray-400 text-4xl">👤</span>
</div>
)}
</div>
<div>
<label htmlFor="avatar" className="cursor-pointer">
<span className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 inline-block">
{avatarPreview ? 'Change Photo' : 'Upload Photo'}
</span>
<input
id="avatar"
type="file"
accept="image/*"
className="hidden"
onChange={handleAvatarChange}
/>
</label>
<p className="text-xs text-gray-500 mt-2 text-center">
Optional, but highly recommended
</p>
</div>
</div>
{/* Full Name */}
<ControlledInputField
control={form.control}
label="Full Name"
placeholder="Enter your full name"
name="fullname"
/>
{/* City */}
<div className="space-y-2">
<label className="block text-sm font-medium text-gray-700">
City <span className="text-red-500">*</span>
</label>
<Controller
control={form.control}
name="location"
render={({ field, fieldState }) => (
<div>
<select
{...field}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
>
<option value="">Select your city</option>
{INDONESIAN_CITIES.map((city) => (
<option key={city} value={city}>
{city}
</option>
))}
</select>
{fieldState.error && (
<p className="text-sm text-red-500 mt-1">
{fieldState.error.message}
</p>
)}
</div>
)}
/>
</div>
{/* Role/Skills */}
<div className="space-y-2">
<label className="block text-sm font-medium text-gray-700">
Role / Skills
</label>
<Controller
control={form.control}
name="skills"
render={({ field }) => (
<div className="space-y-2">
<div className="grid grid-cols-2 gap-2">
{ROLE_OPTIONS.map((role) => (
<label
key={role}
className="flex items-center space-x-2 cursor-pointer"
>
<input
type="checkbox"
checked={field.value?.includes(role)}
onChange={(e) => {
const newValue = e.target.checked
? [...(field.value || []), role]
: (field.value || []).filter((v) => v !== role);
field.onChange(newValue);
}}
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<span className="text-sm">{role}</span>
</label>
))}
</div>
</div>
)}
/>
</div>
{/* Bio */}
<div className="space-y-2">
<label className="block text-sm font-medium text-gray-700">
Bio <span className="text-gray-400">(Optional)</span>
</label>
<Controller
control={form.control}
name="bio"
render={({ field, fieldState }) => (
<div>
<Textarea
{...field}
placeholder="Tell us about yourself..."
rows={4}
className="w-full"
/>
{fieldState.error && (
<p className="text-sm text-red-500 mt-1">
{fieldState.error.message}
</p>
)}
</div>
)}
/>
</div>
<Button
className="w-full"
type="submit"
disabled={!form.formState.isValid || isUpdating || isUploading}
>
{isUpdating || isUploading ? 'Saving...' : 'Complete Setup'}
</Button>
</form>
</div>
</div>
);
};
export default UserOnboardingPage;
-759
View File
@@ -1,759 +0,0 @@
import { useNavigate } from 'react-router';
import { useState } from 'react';
export default function HomePage() {
const navigate = useNavigate();
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
const [openFaq, setOpenFaq] = useState<number | null>(0);
const faqs = [
{
question: 'Siapa yang bisa mengikuti hackathon ini?',
answer:
'Hackathon ini terbuka untuk mahasiswa, fresh graduate, dan profesional muda yang memiliki passion di bidang teknologi. Peserta dapat mendaftar secara tim.',
},
{
question: 'Apakah ada biaya pendaftaran?',
answer:
'Tidak, hackathon ini gratis dan terbuka untuk semua peserta yang memenuhi kriteria.',
},
{
question: 'Apa tema hackathon kali ini?',
answer:
'Tema hackathon kali ini adalah "Inovasi AI: Mendorong Usaha Lokal dengan AI Inklusif".',
},
{
question: 'Bagaimana format pelaksanaannya?',
answer:
'Hackathon dilaksanakan secara online dengan berbagai tahap mulai dari pendaftaran, technical meeting, tahap penyisihan, hingga final.',
},
{
question: 'Apa hadiah lombanya?',
answer:
'Total hadiah senilai Rp14.500.000 dengan juara 1 mendapat Rp6.000.000, juara 2 Rp4.000.000, juara 3 Rp2.500.000, dan juara kategori lainnya Rp2.000.000.',
},
{
question: 'Apakah harus membentuk tim? Boleh solo?',
answer:
'Ya, peserta harus membentuk tim dengan maksimal 3 orang per tim.',
},
{
question: 'Apakah boleh menggunakan AI (vibe coding)?',
answer:
'Ya, peserta diperbolehkan menggunakan AI tools untuk membantu development.',
},
{
question: 'Apa project wajib di-deploy?',
answer:
'Ya, project harus di-deploy dan dapat diakses secara online untuk penilaian.',
},
{
question: 'Apakah peserta mendapatkan sertifikat?',
answer:
'Ya, semua peserta yang menyelesaikan hackathon akan mendapatkan sertifikat.',
},
{
question: 'Website error dan terjadi masalah?',
answer:
'Silakan hubungi kami melalui grup WA Hackathon atau email imphnen@gmail.com.',
},
];
return (
<div className="min-h-screen bg-white">
{/* Navigation */}
<nav className="flex items-center justify-between px-4 md:px-8 py-4 border-b border-gray-200 bg-white sticky top-0 z-50">
<div className="flex items-center gap-2">
<span className="text-lg md:text-xl font-bold">IMPHNEN</span>
<span className="text-lg md:text-xl font-bold text-blue-600">
Hackathon
</span>
</div>
{/* Desktop Menu */}
<div className="hidden md:flex items-center gap-6 lg:gap-8">
<a
href="#timeline"
className="text-gray-600 hover:text-gray-900 transition-colors"
>
Timeline
</a>
<a
href="#hadiah"
className="text-gray-600 hover:text-gray-900 transition-colors"
>
Hadiah
</a>
<a
href="#faq"
className="text-gray-600 hover:text-gray-900 transition-colors"
>
FAQ
</a>
<button
onClick={() => navigate('/auth/login')}
className="px-4 lg:px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors text-sm lg:text-base"
>
Daftar Sekarang
</button>
</div>
{/* Mobile Menu Button */}
<button
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
className="md:hidden p-2"
>
<svg
className="w-6 h-6"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 6h16M4 12h16M4 18h16"
/>
</svg>
</button>
</nav>
{/* Mobile Menu */}
{mobileMenuOpen && (
<div className="md:hidden border-b border-gray-200 bg-white">
<div className="flex flex-col gap-4 px-4 py-4">
<a href="#timeline" className="text-gray-600 hover:text-gray-900">
Timeline
</a>
<a href="#hadiah" className="text-gray-600 hover:text-gray-900">
Hadiah
</a>
<a href="#faq" className="text-gray-600 hover:text-gray-900">
FAQ
</a>
<a href="#masuk" className="text-gray-600 hover:text-gray-900">
Masuk
</a>
<button
onClick={() => navigate('/auth/login')}
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors text-center"
>
Daftar Sekarang
</button>
</div>
</div>
)}
{/* Hero Section */}
<section className="flex flex-col items-center justify-center px-4 md:px-8 py-10 md:py-20 bg-gradient-to-b from-white to-gray-50">
{/* Logos */}
<div className="flex items-center gap-4 md:gap-8 lg:gap-12 mb-8 md:mb-12 lg:mb-16 flex-wrap justify-center">
<div className="flex items-center">
<span className="text-3xl md:text-5xl lg:text-6xl font-bold text-blue-600">
IMPHNEN
</span>
</div>
<span className="text-3xl md:text-5xl lg:text-6xl font-bold text-gray-400">
×
</span>
<div className="flex items-center">
<span className="text-3xl md:text-5xl lg:text-6xl font-bold">
Kolosal
</span>
</div>
</div>
{/* Title */}
<h1 className="text-5xl md:text-7xl lg:text-9xl font-bold text-gray-900 mb-6 md:mb-10 lg:mb-12 text-center">
Hackathon
</h1>
{/* Subtitle */}
<p className="text-xl md:text-3xl lg:text-4xl text-blue-600 font-semibold mb-6 md:mb-8 text-center px-4">
"Inovasi AI: Mendorong Usaha Lokal dengan AI Inklusif"
</p>
{/* Description */}
<p className="text-base md:text-xl lg:text-2xl text-gray-600 max-w-xs md:max-w-2xl lg:max-w-3xl text-center mb-8 md:mb-12 px-4">
Kompetisi pengembangan teknologi untuk menciptakan
<br className="hidden md:block" /> solusi inovatif yang menghadirkan
dampak nyata
</p>
{/* Status */}
<div className="flex flex-col md:flex-row items-center gap-4 md:gap-8 mb-10 md:mb-16 text-sm md:text-lg lg:text-xl text-gray-600">
<div className="flex items-center gap-2 md:gap-3">
<svg
className="w-5 h-5 md:w-6 md:h-6"
fill="currentColor"
viewBox="0 0 20 20"
>
<circle cx="10" cy="10" r="8" fill="#10b981" />
</svg>
<span>Online</span>
</div>
<div className="flex items-center gap-2 md:gap-3 text-center">
<svg
className="w-5 h-5 md:w-6 md:h-6"
fill="currentColor"
viewBox="0 0 20 20"
>
<path d="M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" />
</svg>
<span>Pendaftaran hingga 30 November 2025</span>
</div>
</div>
{/* CTA Buttons */}
<div className="flex flex-col md:flex-row gap-4 w-full md:w-auto px-4 md:px-0">
<button
onClick={() => navigate('/auth/login')}
className="w-full md:w-auto px-6 md:px-8 py-3 bg-blue-600 text-white text-base md:text-lg rounded-lg hover:bg-blue-700 transition-colors text-center font-semibold"
>
Daftar Sekarang
</button>
<button className="w-full md:w-auto px-6 md:px-8 py-3 bg-white text-gray-900 text-base md:text-lg rounded-lg border-2 border-gray-300 hover:border-gray-400 transition-colors text-center font-semibold">
Gabung Grup WA Hackathon
</button>
</div>
{/* Scroll indicator */}
<div className="mt-12 md:mt-20">
<svg
className="w-6 h-6 text-gray-400 animate-bounce"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 14l-7 7m0 0l-7-7m7 7V3"
/>
</svg>
</div>
</section>
{/* About Section */}
<section className="py-16 md:py-24 px-4 md:px-8 bg-white">
<div className="max-w-4xl mx-auto text-center">
<h2 className="text-3xl md:text-5xl font-bold mb-6">
Tentang <span className="text-blue-600">Hackathon</span>
</h2>
<p className="text-lg md:text-xl text-gray-600 mb-6">
<span className="font-semibold text-gray-900">Hackathon</span>{' '}
adalah kompetisi pengembangan teknologi yang mengajak talenta muda
untuk berkolaborasi dalam menciptakan solusi inovatif.
</p>
<p className="text-lg md:text-xl text-gray-600">
IMPHNEN bersama Kolosal.ai mengadakan Hackathon dengan tema lomba{' '}
<span className="font-semibold text-blue-600">
"Inovasi AI: Mendorong Usaha Lokal dengan AI Inklusif"
</span>
.
</p>
</div>
</section>
{/* Prizes Section */}
<section id="hadiah" className="py-16 md:py-24 px-4 md:px-8 bg-gray-50">
<div className="max-w-6xl mx-auto">
<div className="text-center mb-12">
<h2 className="text-3xl md:text-5xl font-bold mb-4">
Hadiah <span className="text-blue-600">Menarik</span>
</h2>
<p className="text-xl md:text-2xl text-blue-600 font-semibold">
Total Prize Pool Rp14.500.000
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
{/* Prize 1 */}
<div className="bg-white rounded-xl p-8 shadow-lg border-2 border-blue-500 transform hover:scale-105 transition-transform">
<div className="flex justify-center mb-4">
<div className="w-16 h-16 bg-blue-100 rounded-full flex items-center justify-center">
<svg
className="w-8 h-8 text-blue-600"
fill="currentColor"
viewBox="0 0 20 20"
>
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z" />
</svg>
</div>
</div>
<h3 className="text-2xl font-bold text-center mb-2">Juara 1</h3>
<p className="text-3xl font-bold text-blue-600 text-center">
Rp6.000.000
</p>
</div>
{/* Prize 2 */}
<div className="bg-white rounded-xl p-8 shadow-lg border-2 border-gray-300 transform hover:scale-105 transition-transform">
<div className="flex justify-center mb-4">
<div className="w-16 h-16 bg-gray-100 rounded-full flex items-center justify-center">
<svg
className="w-8 h-8 text-gray-600"
fill="currentColor"
viewBox="0 0 20 20"
>
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z" />
</svg>
</div>
</div>
<h3 className="text-2xl font-bold text-center mb-2">Juara 2</h3>
<p className="text-3xl font-bold text-blue-600 text-center">
Rp4.000.000
</p>
</div>
{/* Prize 3 */}
<div className="bg-white rounded-xl p-8 shadow-lg border-2 border-orange-300 transform hover:scale-105 transition-transform">
<div className="flex justify-center mb-4">
<div className="w-16 h-16 bg-orange-100 rounded-full flex items-center justify-center">
<svg
className="w-8 h-8 text-orange-600"
fill="currentColor"
viewBox="0 0 20 20"
>
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z" />
</svg>
</div>
</div>
<h3 className="text-2xl font-bold text-center mb-2">Juara 3</h3>
<p className="text-3xl font-bold text-orange-600 text-center">
Rp2.500.000
</p>
</div>
{/* Special Prize */}
<div className="bg-white rounded-xl p-8 shadow-lg border-2 border-purple-300 transform hover:scale-105 transition-transform">
<div className="flex justify-center mb-4">
<div className="w-16 h-16 bg-purple-100 rounded-full flex items-center justify-center">
<svg
className="w-8 h-8 text-purple-600"
fill="currentColor"
viewBox="0 0 20 20"
>
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z" />
</svg>
</div>
</div>
<h3 className="text-2xl font-bold text-center mb-2">
Juara Kategori Lainnya
</h3>
<p className="text-3xl font-bold text-purple-600 text-center">
Rp2.000.000
</p>
</div>
</div>
</div>
</section>
{/* Timeline Section */}
<section id="timeline" className="py-16 md:py-24 px-4 md:px-8 bg-white">
<div className="max-w-4xl mx-auto">
<div className="text-center mb-12">
<h2 className="text-3xl md:text-5xl font-bold mb-4">
Timeline <span className="text-blue-600">Acara</span>
</h2>
<p className="text-lg text-gray-600">
Jadwal lengkap pelaksanaan hackathon
</p>
</div>
<div className="space-y-8">
{/* Timeline Item 1 */}
<div className="flex gap-6">
<div className="flex flex-col items-center">
<div className="w-4 h-4 bg-blue-600 rounded-full"></div>
<div className="w-0.5 h-full bg-gray-300"></div>
</div>
<div className="flex-1 pb-8">
<p className="text-blue-600 font-semibold mb-2">
30 November 2025
</p>
<h3 className="text-xl font-bold mb-2">Penutupan Registrasi</h3>
<p className="text-gray-600">
Batas akhir pendaftaran peserta hackathon.
</p>
</div>
</div>
{/* Timeline Item 2 */}
<div className="flex gap-6">
<div className="flex flex-col items-center">
<div className="w-4 h-4 bg-blue-600 rounded-full"></div>
<div className="w-0.5 h-full bg-gray-300"></div>
</div>
<div className="flex-1 pb-8">
<p className="text-blue-600 font-semibold mb-2">
30 November 2025
</p>
<h3 className="text-xl font-bold mb-2">Technical Meeting</h3>
<p className="text-gray-600">
Akan diadakan technical meeting terkait lomba melalui Google
Meet. Stay tune di grup WA Hackathon.
</p>
</div>
</div>
{/* Timeline Item 3 */}
<div className="flex gap-6">
<div className="flex flex-col items-center">
<div className="w-4 h-4 bg-blue-600 rounded-full"></div>
<div className="w-0.5 h-full bg-gray-300"></div>
</div>
<div className="flex-1 pb-8">
<p className="text-blue-600 font-semibold mb-2">
1 - 7 Desember 2025
</p>
<h3 className="text-xl font-bold mb-2">Tahap Penyisihan</h3>
<p className="text-gray-600">
Peserta mengerjakan tantangan yang diberikan.
</p>
</div>
</div>
{/* Timeline Item 4 */}
<div className="flex gap-6">
<div className="flex flex-col items-center">
<div className="w-4 h-4 bg-blue-600 rounded-full"></div>
<div className="w-0.5 h-full bg-gray-300"></div>
</div>
<div className="flex-1 pb-8">
<p className="text-blue-600 font-semibold mb-2">
8 - 14 Desember 2025
</p>
<h3 className="text-xl font-bold mb-2">Penilaian & Webinar</h3>
<p className="text-gray-600">
Proses penilaian oleh juri dan sesi webinar.
</p>
</div>
</div>
{/* Timeline Item 5 */}
<div className="flex gap-6">
<div className="flex flex-col items-center">
<div className="w-4 h-4 bg-blue-600 rounded-full"></div>
</div>
<div className="flex-1">
<p className="text-blue-600 font-semibold mb-2">
15 Desember 2025
</p>
<h3 className="text-xl font-bold mb-2">Pengumuman & Final</h3>
<p className="text-gray-600">
Presentasi final dan pengumuman pemenang.
</p>
</div>
</div>
</div>
</div>
</section>
{/* Judges Section */}
<section className="py-16 md:py-24 px-4 md:px-8 bg-gray-50">
<div className="max-w-6xl mx-auto">
<div className="text-center mb-12">
<h2 className="text-3xl md:text-5xl font-bold mb-4">
Dewan <span className="text-blue-600">Juri</span>
</h2>
<p className="text-lg text-gray-600">
Perwakilan dari IMPHNEN dan Kolosal.ai yang akan menilai karya
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
{/* Judge 1 */}
<div className="bg-white rounded-xl p-8 shadow-lg text-center">
<div className="w-24 h-24 bg-gray-200 rounded-full mx-auto mb-4"></div>
<h3 className="text-xl font-bold mb-1">
Alifais Farrel Ramdhani
</h3>
<p className="text-blue-600 font-semibold mb-1">CTO</p>
<p className="text-gray-600">Kolosal.ai</p>
</div>
{/* Judge 2 */}
<div className="bg-white rounded-xl p-8 shadow-lg text-center">
<div className="w-24 h-24 bg-gray-200 rounded-full mx-auto mb-4"></div>
<h3 className="text-xl font-bold mb-1">Anka Tama</h3>
<p className="text-blue-600 font-semibold mb-1">Admin</p>
<p className="text-gray-600">IMPHNEN</p>
</div>
{/* Judge 3 */}
<div className="bg-white rounded-xl p-8 shadow-lg text-center">
<div className="w-24 h-24 bg-gray-200 rounded-full mx-auto mb-4"></div>
<h3 className="text-xl font-bold mb-1">Hafid Nur</h3>
<p className="text-blue-600 font-semibold mb-1">Moderator</p>
<p className="text-gray-600">IMPHNEN</p>
</div>
</div>
</div>
</section>
{/* FAQ Section */}
<section id="faq" className="py-16 md:py-24 px-4 md:px-8 bg-white">
<div className="max-w-4xl mx-auto">
<div className="text-center mb-12">
<h2 className="text-3xl md:text-5xl font-bold mb-4 text-blue-600">
FAQ
</h2>
<p className="text-lg text-gray-600">
Pertanyaan yang Sering Diajukan
</p>
</div>
<div className="space-y-4">
{faqs.map((faq, index) => (
<div key={index} className="border border-gray-200 rounded-lg">
<button
onClick={() => setOpenFaq(openFaq === index ? null : index)}
className="w-full px-6 py-4 flex items-center justify-between text-left hover:bg-gray-50 transition-colors"
>
<span className="font-semibold text-gray-900">
{faq.question}
</span>
<svg
className={`w-5 h-5 text-blue-600 transform transition-transform ${
openFaq === index ? 'rotate-180' : ''
}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 9l-7 7-7-7"
/>
</svg>
</button>
{openFaq === index && (
<div className="px-6 pb-4 text-gray-600">{faq.answer}</div>
)}
</div>
))}
</div>
</div>
</section>
{/* Sponsors Section */}
<section className="py-16 md:py-24 px-4 md:px-8 bg-gray-50">
<div className="max-w-6xl mx-auto text-center">
<h2 className="text-3xl md:text-5xl font-bold mb-4">
Sponsor & <span className="text-blue-600">Partner</span>
</h2>
<p className="text-lg text-gray-600 mb-12">
Acara ini sepenuhnya disponsori oleh
</p>
<div className="flex justify-center">
<div className="bg-white rounded-xl p-8 shadow-lg inline-block">
<span className="text-3xl font-bold">Kolosal</span>
</div>
</div>
</div>
</section>
{/* CTA Section */}
<section
id="masuk"
className="py-16 md:py-24 px-4 md:px-8 bg-gradient-to-b from-white to-blue-50"
>
<div className="max-w-4xl mx-auto text-center">
<h2 className="text-3xl md:text-5xl font-bold mb-6">
Segera Daftarkan <span className="text-blue-600">Timmu!</span>
</h2>
<p className="text-lg md:text-xl text-gray-600 mb-8">
Jangan lewatkan kesempatan emas untuk bersaing dengan developer
terbaik,
<br className="hidden md:block" />
belajar dari para ahli, dan memenangkan hadiah jutaan rupiah!
</p>
<div className="flex flex-col md:flex-row gap-4 justify-center">
<button
onClick={() => navigate('/auth/login')}
className="px-8 py-3 bg-blue-600 text-white text-lg rounded-lg hover:bg-blue-700 transition-colors font-semibold"
>
Daftar Sekarang
</button>
<button className="px-8 py-3 bg-white text-gray-900 text-lg rounded-lg border-2 border-gray-300 hover:border-gray-400 transition-colors font-semibold">
Gabung Grup WA Hackathon
</button>
</div>
<p className="text-sm text-gray-500 mt-6">
Pendaftaran ditutup pada{' '}
<span className="text-blue-600 font-semibold">
30 November 2025
</span>
</p>
</div>
</section>
{/* Footer */}
<footer className="bg-gray-900 text-white py-12 px-4 md:px-8">
<div className="max-w-6xl mx-auto">
<div className="grid grid-cols-1 md:grid-cols-3 gap-8 mb-8">
{/* Brand */}
<div>
<div className="flex items-center gap-2 mb-4">
<span className="text-2xl font-bold">IMPHNEN</span>
<span className="text-2xl font-bold text-blue-500">
Hackathon
</span>
</div>
<p className="text-gray-400 text-sm">
Wujudkan ide brilian mu menjadi solusi nyata. Bergabunglah dalam
IMPHNEN Hackathon dan jadilah bagian dari perubahan teknologi
masa depan.
</p>
<div className="flex gap-4 mt-4">
<a
href="#"
className="text-gray-400 hover:text-white transition-colors"
>
<svg
className="w-6 h-6"
fill="currentColor"
viewBox="0 0 24 24"
>
<path d="M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z" />
</svg>
</a>
<a
href="#"
className="text-gray-400 hover:text-white transition-colors"
>
<svg
className="w-6 h-6"
fill="currentColor"
viewBox="0 0 24 24"
>
<path d="M12 2.163c3.204 0 3.584.012 4.85.07 3.252.148 4.771 1.691 4.919 4.919.058 1.265.069 1.645.069 4.849 0 3.205-.012 3.584-.069 4.849-.149 3.225-1.664 4.771-4.919 4.919-1.266.058-1.644.07-4.85.07-3.204 0-3.584-.012-4.849-.07-3.26-.149-4.771-1.699-4.919-4.92-.058-1.265-.07-1.644-.07-4.849 0-3.204.013-3.583.07-4.849.149-3.227 1.664-4.771 4.919-4.919 1.266-.057 1.645-.069 4.849-.069zm0-2.163c-3.259 0-3.667.014-4.947.072-4.358.2-6.78 2.618-6.98 6.98-.059 1.281-.073 1.689-.073 4.948 0 3.259.014 3.668.072 4.948.2 4.358 2.618 6.78 6.98 6.98 1.281.058 1.689.072 4.948.072 3.259 0 3.668-.014 4.948-.072 4.354-.2 6.782-2.618 6.979-6.98.059-1.28.073-1.689.073-4.948 0-3.259-.014-3.667-.072-4.947-.196-4.354-2.617-6.78-6.979-6.98-1.281-.059-1.69-.073-4.949-.073z" />
</svg>
</a>
<a
href="#"
className="text-gray-400 hover:text-white transition-colors"
>
<svg
className="w-6 h-6"
fill="currentColor"
viewBox="0 0 24 24"
>
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.840 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z" />
</svg>
</a>
</div>
</div>
{/* Quick Links */}
<div>
<h3 className="font-bold text-lg mb-4">Quick Links</h3>
<ul className="space-y-2 text-gray-400">
<li>
<a
href="#timeline"
className="hover:text-white transition-colors"
>
Timeline
</a>
</li>
<li>
<a
href="#hadiah"
className="hover:text-white transition-colors"
>
Hadiah
</a>
</li>
<li>
<a href="#faq" className="hover:text-white transition-colors">
FAQ
</a>
</li>
<li>
<a
href="#masuk"
className="hover:text-white transition-colors"
>
Daftar
</a>
</li>
</ul>
</div>
{/* Contact */}
<div>
<h3 className="font-bold text-lg mb-4">Contact</h3>
<ul className="space-y-2 text-gray-400">
<li className="flex items-center gap-2">
<svg
className="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"
/>
</svg>
imphnen@gmail.com
</li>
<li className="flex items-center gap-2">
<svg
className="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M17 8h2a2 2 0 012 2v6a2 2 0 01-2 2h-2v4l-4-4H9a1.994 1.994 0 01-1.414-.586m0 0L11 14h4a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2v4l.586-.586z"
/>
</svg>
WA Group Hackathon
</li>
<li className="flex items-center gap-2">
<svg
className="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9"
/>
</svg>
IMPHNEN.dev
</li>
</ul>
</div>
</div>
<div className="border-t border-gray-800 pt-8 text-center text-gray-400 text-sm">
<p>
© 2025 IMPHNEN - Ingin Menjadi Programmer Handal Namun Enggan
Ngoding. All rights reserved.
</p>
</div>
</div>
</footer>
</div>
);
}
@@ -1,210 +0,0 @@
import { FC, ReactElement, useState, useRef, useEffect } from 'react';
import { Button } from '@imphnen-frontend-service/ui/atoms';
import { useNavigate, useParams } from 'react-router';
import { useTeamById, useTeamMessages, useSendMessage, useDeleteMessage, useAuthStore } from '@imphnen-frontend-service/service';
import { toast } from 'sonner';
const TeamChatPage: FC = (): ReactElement => {
const { teamId } = useParams<{ teamId: string }>();
const navigate = useNavigate();
const { session } = useAuthStore();
const [message, setMessage] = useState('');
const messagesEndRef = useRef<HTMLDivElement>(null);
const { data: teamData } = useTeamById(teamId || '');
const { data: messages, isLoading } = useTeamMessages(teamId || '');
const { mutateAsync: sendMessage, isPending: isSending } = useSendMessage(teamId || '');
const { mutateAsync: deleteMessage } = useDeleteMessage(teamId || '');
const team = teamData?.data;
const currentUserId = session?.user?.id;
// Auto-scroll to bottom when new messages arrive
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages]);
const handleSendMessage = async (e: React.FormEvent) => {
e.preventDefault();
if (!message.trim() || isSending) return;
try {
await sendMessage(message.trim());
setMessage('');
} catch (error) {
console.error('Failed to send message:', error);
toast.error('Failed to send message');
}
};
const handleDeleteMessage = async (messageId: string) => {
if (!confirm('Are you sure you want to delete this message?')) return;
try {
await deleteMessage(messageId);
toast.success('Message deleted');
} catch (error) {
console.error('Failed to delete message:', error);
toast.error('Failed to delete message');
}
};
const formatTime = (timestamp: string) => {
const date = new Date(timestamp);
const now = new Date();
const diffInMs = now.getTime() - date.getTime();
const diffInMins = Math.floor(diffInMs / 60000);
if (diffInMins < 1) return 'Just now';
if (diffInMins < 60) return `${diffInMins}m ago`;
if (diffInMins < 1440) return `${Math.floor(diffInMins / 60)}h ago`;
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
};
return (
<div className="flex flex-col h-screen bg-gray-50">
{/* Header */}
<div className="bg-white border-b shadow-sm">
<div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900">Team Chat</h1>
<p className="text-gray-600 text-sm mt-0.5">{team?.name}</p>
</div>
<Button variant="secondary" onClick={() => navigate(`/teams/${teamId}`)}>
Back to Team
</Button>
</div>
</div>
</div>
{/* Messages Container */}
<div className="flex-1 overflow-y-auto">
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
{isLoading ? (
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
</div>
) : messages && messages.length > 0 ? (
<div className="space-y-4">
{messages.map((msg) => {
const isOwnMessage = msg.user_id === currentUserId;
const isLeader = team?.leader_id === currentUserId;
const canDelete = isOwnMessage || isLeader;
return (
<div
key={msg.id}
className={`flex ${isOwnMessage ? 'justify-end' : 'justify-start'}`}
>
<div className={`flex gap-3 max-w-lg ${isOwnMessage ? 'flex-row-reverse' : 'flex-row'}`}>
{/* Avatar */}
<div className="flex-shrink-0">
{msg.user?.avatar ? (
<img
src={msg.user.avatar}
alt={msg.user.fullname}
className="w-10 h-10 rounded-full object-cover"
/>
) : (
<div className="w-10 h-10 rounded-full bg-blue-600 flex items-center justify-center text-white font-semibold">
{msg.user?.fullname?.charAt(0) || '?'}
</div>
)}
</div>
{/* Message Bubble */}
<div className={`flex-1 ${isOwnMessage ? 'text-right' : 'text-left'}`}>
<div className={`inline-block ${isOwnMessage ? 'items-end' : 'items-start'}`}>
<div className="flex items-baseline gap-2 mb-1">
<span className="font-semibold text-sm text-gray-900">
{isOwnMessage ? 'You' : msg.user?.fullname}
</span>
<span className="text-xs text-gray-500">
{formatTime(msg.created_at)}
</span>
</div>
<div
className={`relative group rounded-2xl px-4 py-2.5 ${
isOwnMessage
? 'bg-blue-600 text-white'
: 'bg-white text-gray-900 border border-gray-200'
}`}
>
<p className="text-sm whitespace-pre-wrap break-words">{msg.message}</p>
{/* Delete button */}
{canDelete && (
<button
onClick={() => handleDeleteMessage(msg.id)}
className={`absolute top-1 ${isOwnMessage ? 'left-1' : 'right-1'} opacity-0 group-hover:opacity-100 transition-opacity p-1 rounded hover:bg-gray-200 ${isOwnMessage ? 'hover:bg-blue-700' : ''}`}
title="Delete message"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
)}
</div>
</div>
</div>
</div>
</div>
);
})}
<div ref={messagesEndRef} />
</div>
) : (
<div className="flex flex-col items-center justify-center h-64 text-center">
<div className="text-6xl mb-4">💬</div>
<h3 className="text-xl font-semibold text-gray-900 mb-2">
No messages yet
</h3>
<p className="text-gray-600">
Be the first to start the conversation!
</p>
</div>
)}
</div>
</div>
{/* Message Input */}
<div className="bg-white border-t shadow-lg">
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
<form onSubmit={handleSendMessage} className="flex gap-3">
<input
type="text"
value={message}
onChange={(e) => setMessage(e.target.value)}
placeholder="Type your message..."
className="flex-1 px-4 py-3 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
disabled={isSending}
/>
<Button
type="submit"
disabled={!message.trim() || isSending}
className="px-6 py-3"
>
{isSending ? (
<div className="flex items-center gap-2">
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
Sending...
</div>
) : (
<div className="flex items-center gap-2">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8" />
</svg>
Send
</div>
)}
</Button>
</form>
</div>
</div>
</div>
);
};
export default TeamChatPage;
@@ -1,347 +0,0 @@
import { FC, ReactElement, useState, useEffect } from 'react';
import { ControlledInputField } from '@imphnen-frontend-service/ui/organisms';
import { Button, Textarea } from '@imphnen-frontend-service/ui/atoms';
import { useNavigate, useParams } from 'react-router';
import { useForm, Controller } from 'react-hook-form';
import { teamUpdateSchema, TTeamUpdateForm, useUpdateTeam, useTeamById, ETeamVisibility, useUploadFile, useAuthStore } from '@imphnen-frontend-service/service';
import { zodResolver } from '@hookform/resolvers/zod';
import INDONESIAN_CITIES from '../../../../constants/cities';
const EditTeamPage: FC = (): ReactElement => {
const { teamId } = useParams<{ teamId: string }>();
const navigate = useNavigate();
const { session } = useAuthStore();
const [logoFile, setLogoFile] = useState<File | null>(null);
const [logoPreview, setLogoPreview] = useState<string>('');
const [bannerFile, setBannerFile] = useState<File | null>(null);
const [bannerPreview, setBannerPreview] = useState<string>('');
const { data: teamData, isLoading: isLoadingTeam } = useTeamById(teamId || '');
const { mutateAsync: updateTeam, isPending: isUpdating } = useUpdateTeam(teamId || '');
const { mutateAsync: uploadFile, isPending: isUploading } = useUploadFile();
const team = teamData?.data;
const currentUserId = session?.user?.id;
const isLeader = currentUserId === team?.leader_id;
const form = useForm<TTeamUpdateForm>({
resolver: zodResolver(teamUpdateSchema),
mode: 'all',
});
useEffect(() => {
if (team) {
form.reset({
name: team.name,
description: team.description,
city: team.city,
visibility: team.visibility,
logo: team.logo,
banner: team.banner,
});
if (team.logo) setLogoPreview(team.logo);
if (team.banner) setBannerPreview(team.banner);
}
}, [team, form]);
if (isLoadingTeam) {
return (
<div className="flex items-center justify-center min-h-screen">
<div className="text-gray-600">Loading team...</div>
</div>
);
}
if (!isLeader) {
return (
<div className="flex flex-col items-center justify-center min-h-screen">
<h2 className="text-2xl font-bold text-gray-900 mb-4">Access Denied</h2>
<p className="text-gray-600 mb-4">Only the team leader can edit team information</p>
<Button onClick={() => navigate(`/teams/${teamId}`)}>Back to Team</Button>
</div>
);
}
const handleLogoChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
setLogoFile(file);
const reader = new FileReader();
reader.onloadend = () => {
setLogoPreview(reader.result as string);
};
reader.readAsDataURL(file);
}
};
const handleBannerChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
setBannerFile(file);
const reader = new FileReader();
reader.onloadend = () => {
setBannerPreview(reader.result as string);
};
reader.readAsDataURL(file);
}
};
const onSubmit = form.handleSubmit(async (data) => {
try {
let logoUrl = data.logo;
let bannerUrl = data.banner;
if (logoFile) {
const logoResult = await uploadFile(logoFile);
logoUrl = logoResult.data.url;
}
if (bannerFile) {
const bannerResult = await uploadFile(bannerFile);
bannerUrl = bannerResult.data.url;
}
await updateTeam({
...data,
logo: logoUrl,
banner: bannerUrl,
});
navigate(`/teams/${teamId}`);
} catch (error) {
console.error('Failed to update team:', error);
}
});
return (
<div className="min-h-screen bg-gray-50">
<div className="bg-white border-b">
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
<h1 className="text-3xl font-bold text-gray-900">Edit Team Info</h1>
<p className="text-gray-600 mt-1">Update your team details</p>
</div>
</div>
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div className="bg-white rounded-lg shadow-md p-8">
<form onSubmit={onSubmit} className="space-y-6">
{/* Banner Upload */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Team Banner
</label>
{bannerPreview ? (
<div className="relative">
<img
src={bannerPreview}
alt="Banner preview"
className="w-full h-48 object-cover rounded-lg border-2 border-gray-200"
/>
<button
type="button"
onClick={() => {
setBannerFile(null);
setBannerPreview('');
form.setValue('banner', null);
}}
className="absolute top-2 right-2 bg-red-500 text-white px-3 py-1 rounded-lg text-sm hover:bg-red-600"
>
Remove
</button>
</div>
) : (
<label className="flex flex-col items-center justify-center w-full h-48 border-2 border-dashed border-gray-300 rounded-lg cursor-pointer hover:bg-gray-50">
<div className="text-center">
<p className="text-gray-500">Click to upload banner</p>
<p className="text-xs text-gray-400 mt-1">1200x400 recommended</p>
</div>
<input
type="file"
accept="image/*"
className="hidden"
onChange={handleBannerChange}
/>
</label>
)}
</div>
{/* Logo Upload */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Team Logo
</label>
<div className="flex items-center space-x-4">
{logoPreview ? (
<img
src={logoPreview}
alt="Logo preview"
className="w-24 h-24 rounded-full object-cover border-2 border-gray-200"
/>
) : (
<div className="w-24 h-24 rounded-full bg-gray-200 flex items-center justify-center">
<span className="text-gray-400 text-3xl">👥</span>
</div>
)}
<div>
<label htmlFor="logo" className="cursor-pointer">
<span className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 inline-block">
{logoPreview ? 'Change Logo' : 'Upload Logo'}
</span>
<input
id="logo"
type="file"
accept="image/*"
className="hidden"
onChange={handleLogoChange}
/>
</label>
{logoPreview && (
<button
type="button"
onClick={() => {
setLogoFile(null);
setLogoPreview('');
form.setValue('logo', null);
}}
className="ml-3 px-4 py-2 bg-red-500 text-white rounded-lg hover:bg-red-600"
>
Remove
</button>
)}
</div>
</div>
</div>
{/* Team Name */}
<ControlledInputField
control={form.control}
label="Team Name"
placeholder="Enter team name"
name="name"
/>
{/* City */}
<div className="space-y-2">
<label className="block text-sm font-medium text-gray-700">
City
</label>
<Controller
control={form.control}
name="city"
render={({ field, fieldState }) => (
<div>
<select
{...field}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
>
<option value="">Select city</option>
{INDONESIAN_CITIES.map((city) => (
<option key={city} value={city}>
{city}
</option>
))}
</select>
{fieldState.error && (
<p className="text-sm text-red-500 mt-1">{fieldState.error.message}</p>
)}
</div>
)}
/>
</div>
{/* Description */}
<div className="space-y-2">
<label className="block text-sm font-medium text-gray-700">
Description
</label>
<Controller
control={form.control}
name="description"
render={({ field, fieldState }) => (
<div>
<Textarea
{...field}
value={field.value || ''}
placeholder="Tell others about your team..."
rows={4}
className="w-full"
/>
{fieldState.error && (
<p className="text-sm text-red-500 mt-1">{fieldState.error.message}</p>
)}
</div>
)}
/>
</div>
{/* Visibility */}
<div className="space-y-2">
<label className="block text-sm font-medium text-gray-700">
Team Visibility
</label>
<Controller
control={form.control}
name="visibility"
render={({ field }) => (
<div className="space-y-3">
<label className="flex items-start space-x-3 cursor-pointer border rounded-lg p-4 hover:bg-gray-50">
<input
type="radio"
{...field}
value={ETeamVisibility.PUBLIC}
checked={field.value === ETeamVisibility.PUBLIC}
className="mt-1"
/>
<div>
<p className="font-medium text-gray-900">Public</p>
<p className="text-sm text-gray-600">
Team will be visible in Browse Teams
</p>
</div>
</label>
<label className="flex items-start space-x-3 cursor-pointer border rounded-lg p-4 hover:bg-gray-50">
<input
type="radio"
{...field}
value={ETeamVisibility.PRIVATE}
checked={field.value === ETeamVisibility.PRIVATE}
className="mt-1"
/>
<div>
<p className="font-medium text-gray-900">Private</p>
<p className="text-sm text-gray-600">
Team hidden, invite-only
</p>
</div>
</label>
</div>
)}
/>
</div>
{/* Submit Button */}
<div className="flex space-x-3 pt-4">
<Button
type="button"
variant="secondary"
className="flex-1"
onClick={() => navigate(`/teams/${teamId}`)}
>
Cancel
</Button>
<Button
type="submit"
className="flex-1"
disabled={isUpdating || isUploading}
>
{isUpdating || isUploading ? 'Saving...' : 'Save Changes'}
</Button>
</div>
</form>
</div>
</div>
</div>
);
};
export default EditTeamPage;
@@ -1,18 +0,0 @@
import { FC, ReactNode } from 'react';
import { Outlet } from 'react-router';
import { Navigation } from '../../../components/navigation';
interface TeamLayoutProps {
children?: ReactNode;
}
export const TeamLayout: FC<TeamLayoutProps> = () => {
return (
<div className="min-h-screen bg-gray-50">
<Navigation />
<Outlet />
</div>
);
};
export default TeamLayout;
@@ -1,294 +0,0 @@
import { FC, ReactElement, useState } from 'react';
import { Button, Input } from '@imphnen-frontend-service/ui/atoms';
import { useNavigate, useParams } from 'react-router';
import {
useTeamById,
useTeamMembers,
useTeamJoinRequests,
useInviteMember,
useRemoveMember,
useRespondToJoinRequest,
ETeamMemberStatus,
inviteMemberSchema,
TInviteMemberForm,
useAuthStore,
} from '@imphnen-frontend-service/service';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
const ManageMembersPage: FC = (): ReactElement => {
const { teamId } = useParams<{ teamId: string }>();
const navigate = useNavigate();
const { session } = useAuthStore();
const [showInviteModal, setShowInviteModal] = useState(false);
const { data: teamData } = useTeamById(teamId || '');
const { data: membersData, isLoading: isLoadingMembers } = useTeamMembers(teamId || '');
const { data: joinRequestsData } = useTeamJoinRequests(teamId || '');
const { mutateAsync: inviteMember, isPending: isInviting } = useInviteMember(teamId || '');
const { mutateAsync: removeMember, isPending: isRemoving } = useRemoveMember(teamId || '');
const { mutateAsync: respondToRequest, isPending: isResponding } = useRespondToJoinRequest(teamId || '');
const team = teamData?.data;
const members = membersData?.data || [];
const joinRequests = Array.isArray(joinRequestsData?.data) ? joinRequestsData.data : [];
const currentUserId = session?.user?.id;
const isLeader = currentUserId === team?.leader_id;
const form = useForm<TInviteMemberForm>({
resolver: zodResolver(inviteMemberSchema),
mode: 'all',
});
if (!isLeader) {
return (
<div className="flex flex-col items-center justify-center min-h-screen">
<h2 className="text-2xl font-bold text-gray-900 mb-4">Access Denied</h2>
<p className="text-gray-600 mb-4">Only the team leader can manage members</p>
<Button onClick={() => navigate(`/teams/${teamId}`)}>Back to Team</Button>
</div>
);
}
const handleInvite = form.handleSubmit(async (data) => {
try {
await inviteMember(data);
setShowInviteModal(false);
form.reset();
} catch (error) {
console.error('Failed to invite member:', error);
}
});
const handleRemove = async (userId: string) => {
// eslint-disable-next-line no-restricted-globals
if (confirm('Are you sure you want to remove this member?')) {
try {
await removeMember(userId);
} catch (error) {
console.error('Failed to remove member:', error);
}
}
};
const handleApproveRequest = async (requestId: string) => {
try {
await respondToRequest({ requestId, action: 'approve' });
} catch (error) {
console.error('Failed to approve request:', error);
}
};
const handleRejectRequest = async (requestId: string) => {
try {
await respondToRequest({ requestId, action: 'reject' });
} catch (error) {
console.error('Failed to reject request:', error);
}
};
return (
<div className="min-h-screen bg-gray-50">
<div className="bg-white border-b">
<div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold text-gray-900">Manage Members</h1>
<p className="text-gray-600 mt-1">{team?.name}</p>
</div>
<div className="flex space-x-3">
<Button onClick={() => setShowInviteModal(true)}>
Invite Member
</Button>
<Button variant="secondary" onClick={() => navigate(`/teams/${teamId}`)}>
Back to Team
</Button>
</div>
</div>
</div>
</div>
<div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-8 space-y-6">
{/* Join Requests */}
{joinRequests.length > 0 && (
<div className="bg-white rounded-lg shadow-md p-6">
<h2 className="text-xl font-bold text-gray-900 mb-4">
Join Requests ({joinRequests.length})
</h2>
<div className="space-y-3">
{joinRequests.map((request) => (
<div key={request.id} className="border rounded-lg p-4">
<div className="flex items-start justify-between">
<div className="flex items-center space-x-3 flex-1">
{request.user.avatar ? (
<img
src={request.user.avatar}
alt={request.user.fullname}
className="w-12 h-12 rounded-full object-cover"
/>
) : (
<div className="w-12 h-12 rounded-full bg-gray-200 flex items-center justify-center">
<span className="text-gray-500">👤</span>
</div>
)}
<div className="flex-1">
<p className="font-medium text-gray-900">{request.user.fullname}</p>
<p className="text-sm text-gray-600">{request.user.email}</p>
{request.user.location && (
<p className="text-sm text-gray-500">📍 {request.user.location}</p>
)}
<p className="text-sm text-gray-700 mt-2 italic">"{request.message}"</p>
</div>
</div>
<div className="flex space-x-2 ml-4">
<Button
size="sm"
onClick={() => handleApproveRequest(request.id)}
disabled={isResponding}
>
Approve
</Button>
<Button
size="sm"
variant="secondary"
onClick={() => handleRejectRequest(request.id)}
disabled={isResponding}
>
Reject
</Button>
</div>
</div>
</div>
))}
</div>
</div>
)}
{/* Current Members */}
<div className="bg-white rounded-lg shadow-md p-6">
<h2 className="text-xl font-bold text-gray-900 mb-4">
Current Members ({members.length})
</h2>
{isLoadingMembers ? (
<p className="text-gray-600">Loading members...</p>
) : (
<div className="space-y-3">
{members.map((member) => (
<div key={member.id} className="border rounded-lg p-4 flex items-center justify-between">
<div className="flex items-center space-x-3">
{member.user.avatar ? (
<img
src={member.user.avatar}
alt={member.user.fullname}
className="w-12 h-12 rounded-full object-cover"
/>
) : (
<div className="w-12 h-12 rounded-full bg-gray-200 flex items-center justify-center">
<span className="text-gray-500">👤</span>
</div>
)}
<div>
<p className="font-medium text-gray-900">{member.user.fullname}</p>
<p className="text-sm text-gray-600">{member.user.email}</p>
{member.user.location && (
<p className="text-sm text-gray-500">📍 {member.user.location}</p>
)}
<div className="flex items-center space-x-2 mt-1">
{member.role === 'leader' && (
<span className="px-2 py-1 bg-blue-100 text-blue-800 rounded text-xs font-medium">
Leader
</span>
)}
{member.status === ETeamMemberStatus.PENDING && (
<span className="px-2 py-1 bg-yellow-100 text-yellow-800 rounded text-xs font-medium">
Pending Invitation
</span>
)}
</div>
</div>
</div>
{member.role !== 'leader' && (
<Button
size="sm"
variant="secondary"
onClick={() => handleRemove(member.user_id)}
disabled={isRemoving}
>
Remove
</Button>
)}
</div>
))}
</div>
)}
</div>
{/* Warning */}
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4">
<p className="text-sm text-yellow-800">
<strong>Note:</strong> Members cannot leave the team without your approval. Only you can remove members from the team.
</p>
</div>
</div>
{/* Invite Member Modal */}
{showInviteModal && (
<div className="fixed inset-0 bg-black/20 backdrop-blur-sm flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-lg shadow-xl max-w-md w-full p-6">
<h2 className="text-2xl font-bold text-gray-900 mb-4">
Invite Member
</h2>
<p className="text-gray-600 mb-4">
Send an invitation to join your team. The invited member will see the invitation on their dashboard after logging in.
</p>
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-3 mb-6">
<p className="text-sm text-yellow-800">
<strong>Important:</strong> The email you enter must match the GitHub email address the member uses to sign in.
</p>
</div>
<form onSubmit={handleInvite} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Email Address
</label>
<Input
{...form.register('email')}
type="email"
placeholder="member@example.com"
/>
{form.formState.errors.email && (
<p className="text-sm text-red-500 mt-1">
{form.formState.errors.email.message}
</p>
)}
</div>
<div className="flex space-x-3">
<Button
type="button"
variant="secondary"
className="flex-1"
onClick={() => {
setShowInviteModal(false);
form.reset();
}}
>
Cancel
</Button>
<Button
type="submit"
className="flex-1"
disabled={!form.formState.isValid || isInviting}
>
{isInviting ? 'Sending...' : 'Send Invitation'}
</Button>
</div>
</form>
</div>
</div>
)}
</div>
);
};
export default ManageMembersPage;
@@ -1,444 +0,0 @@
import { FC, ReactElement, useState } from 'react';
import { Button } from '@imphnen-frontend-service/ui/atoms';
import { Link, useParams, useNavigate } from 'react-router';
import { useTeamById, useTeamMembers, useInviteMember, useTeamJoinRequests, useRespondToJoinRequest, ETeamMemberRole, useAuthStore } from '@imphnen-frontend-service/service';
import { toast } from 'sonner';
const MAX_TEAM_MEMBERS = 5;
const TeamDashboardPage: FC = (): ReactElement => {
const { teamId } = useParams<{ teamId: string }>();
const navigate = useNavigate();
const { session } = useAuthStore();
const [showInviteModal, setShowInviteModal] = useState(false);
const [showJoinRequestsModal, setShowJoinRequestsModal] = useState(false);
const [inviteEmail, setInviteEmail] = useState('');
const { data: teamData, isLoading: isLoadingTeam } = useTeamById(teamId || '');
const { data: membersData, isLoading: isLoadingMembers } = useTeamMembers(teamId || '');
const { data: joinRequestsData } = useTeamJoinRequests(teamId || '', !!teamId);
const { mutateAsync: inviteMember, isPending: isInviting } = useInviteMember(teamId || '');
const { mutateAsync: respondToJoinRequest, isPending: isResponding } = useRespondToJoinRequest(teamId || '');
const team = teamData?.data;
const members = membersData?.data || [];
const joinRequests = joinRequestsData?.data || [];
const pendingJoinRequests = joinRequests.filter((req: any) => req.status === 'pending');
const currentUserId = session?.user?.id;
const isLeader = currentUserId === team?.leader_id;
const canInvite = isLeader && members.length < MAX_TEAM_MEMBERS;
console.log('Leader check:', { currentUserId, leaderId: team?.leader_id, isLeader });
const handleInviteMember = async (e: React.FormEvent) => {
e.preventDefault();
if (!inviteEmail.trim() || isInviting) return;
try {
await inviteMember({ email: inviteEmail.trim() });
toast.success('Invitation sent successfully!');
setInviteEmail('');
setShowInviteModal(false);
} catch (error) {
console.error('Failed to invite member:', error);
toast.error('Failed to send invitation');
}
};
const handleRespondToJoinRequest = async (requestId: string, action: 'approve' | 'reject') => {
try {
await respondToJoinRequest({ requestId, action });
toast.success(action === 'approve' ? 'Request approved!' : 'Request rejected');
} catch (error) {
console.error('Failed to respond to join request:', error);
toast.error('Failed to process request');
}
};
if (isLoadingTeam || isLoadingMembers) {
return (
<div className="flex items-center justify-center min-h-screen">
<div className="text-gray-600">Loading team...</div>
</div>
);
}
if (!team) {
return (
<div className="flex flex-col items-center justify-center min-h-screen">
<h2 className="text-2xl font-bold text-gray-900 mb-4">Team not found</h2>
<Button onClick={() => navigate('/dashboard')}>Back to Dashboard</Button>
</div>
);
}
return (
<div className="min-h-screen bg-gray-50">
{/* Header with Banner */}
<div className="bg-white border-b">
{team.banner && (
<div className="w-full h-48 overflow-hidden">
<img
src={team.banner}
alt={team.name}
className="w-full h-full object-cover"
/>
</div>
)}
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
<div className="flex items-start justify-between">
<div className="flex items-center space-x-4">
{team.logo && (
<img
src={team.logo}
alt={team.name}
className="w-20 h-20 rounded-full object-cover border-4 border-white shadow-lg -mt-10"
/>
)}
<div>
<h1 className="text-3xl font-bold text-gray-900">{team.name}</h1>
<p className="text-gray-600 mt-1">📍 {team.city}</p>
<div className="flex items-center space-x-4 mt-2">
<span className="text-sm text-gray-500">
{members.length} {members.length === 1 ? 'Member' : 'Members'}
</span>
<span className="text-sm text-gray-500">
{team.visibility === 'public' ? '🌐 Public' : '🔒 Private'}
</span>
{isLeader && (
<span className="px-3 py-1 bg-blue-600 text-white rounded-md text-sm font-semibold shadow-sm">
👑 Team Leader
</span>
)}
</div>
</div>
</div>
<Link to="/dashboard">
<Button variant="secondary">Back to Dashboard</Button>
</Link>
</div>
</div>
</div>
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div className="grid gap-6 lg:grid-cols-3">
{/* Main Content */}
<div className="lg:col-span-2 space-y-6">
{/* Team Description */}
<div className="bg-white rounded-lg shadow-md p-6">
<h2 className="text-xl font-bold text-gray-900 mb-4">About Team</h2>
<p className="text-gray-700 whitespace-pre-wrap">{team.description}</p>
</div>
{/* Team Actions - Only for Leader */}
{isLeader && (
<div className="bg-white rounded-lg shadow-md p-6">
<h2 className="text-xl font-bold text-gray-900 mb-4">Team Management</h2>
<div className="grid gap-3 sm:grid-cols-2">
<Button
className="w-full"
variant="secondary"
onClick={() => setShowInviteModal(true)}
disabled={!canInvite}
>
Invite Member {!canInvite && `(${members.length}/${MAX_TEAM_MEMBERS})`}
</Button>
<Button
className="w-full relative"
variant="secondary"
onClick={() => setShowJoinRequestsModal(true)}
>
📩 Join Requests
{pendingJoinRequests.length > 0 && (
<span className="absolute -top-2 -right-2 bg-red-500 text-white text-xs font-bold rounded-full w-6 h-6 flex items-center justify-center">
{pendingJoinRequests.length}
</span>
)}
</Button>
<Link to={`/teams/${teamId}/edit`}>
<Button className="w-full" variant="secondary">
Edit Team Info
</Button>
</Link>
<Link to={`/teams/${teamId}/members`}>
<Button className="w-full" variant="secondary">
👥 Manage Members
</Button>
</Link>
<Link to={`/teams/${teamId}/chat`}>
<Button className="w-full" variant="secondary">
💬 Team Chat
</Button>
</Link>
<Link to={`/teams/${teamId}/submit`}>
<Button className="w-full">
🚀 Submit Project
</Button>
</Link>
</div>
{!canInvite && members.length >= MAX_TEAM_MEMBERS && (
<p className="text-sm text-gray-600 mt-3 text-center">
Maximum team size reached ({MAX_TEAM_MEMBERS} members)
</p>
)}
</div>
)}
{/* Quick Actions for Members */}
{!isLeader && (
<div className="bg-white rounded-lg shadow-md p-6">
<h2 className="text-xl font-bold text-gray-900 mb-4">Quick Actions</h2>
<div className="grid gap-3 sm:grid-cols-2">
<Link to={`/teams/${teamId}/chat`}>
<Button className="w-full" variant="secondary">
💬 Team Chat
</Button>
</Link>
{team.has_submission && (
<Link to={`/teams/${teamId}/submission`}>
<Button className="w-full" variant="secondary">
📄 View Submission
</Button>
</Link>
)}
</div>
</div>
)}
{/* Submission Status */}
{team.has_submission && (
<div className="bg-green-50 border border-green-200 rounded-lg p-6">
<div className="flex items-center space-x-3">
<span className="text-3xl"></span>
<div>
<h3 className="font-bold text-green-900">Project Submitted</h3>
<p className="text-green-700 text-sm">
Your team has successfully submitted a project
</p>
</div>
</div>
<Link to={`/teams/${teamId}/submission`}>
<Button className="mt-4 w-full" variant="secondary">
View Submission Details
</Button>
</Link>
</div>
)}
</div>
{/* Sidebar */}
<div className="space-y-6">
{/* Team Leader */}
<div className="bg-white rounded-lg shadow-md p-6">
<h3 className="font-bold text-gray-900 mb-4">Team Leader</h3>
{team.leader && (
<div className="flex items-center space-x-3">
{team.leader.avatar ? (
<img
src={team.leader.avatar}
alt={team.leader.fullname}
className="w-12 h-12 rounded-full object-cover"
/>
) : (
<div className="w-12 h-12 rounded-full bg-gray-200 flex items-center justify-center">
<span className="text-gray-500">👤</span>
</div>
)}
<div>
<p className="font-medium text-gray-900">{team.leader.fullname}</p>
<p className="text-sm text-gray-600">{team.leader.email}</p>
</div>
</div>
)}
</div>
{/* Team Members */}
<div className="bg-white rounded-lg shadow-md p-6">
<h3 className="font-bold text-gray-900 mb-4">
Members ({members.length})
</h3>
<div className="space-y-3">
{members.map((member) => (
<div key={member.id} className="flex items-center space-x-3">
{member.user.avatar ? (
<img
src={member.user.avatar}
alt={member.user.fullname}
className="w-10 h-10 rounded-full object-cover"
/>
) : (
<div className="w-10 h-10 rounded-full bg-gray-200 flex items-center justify-center">
<span className="text-gray-500 text-sm">👤</span>
</div>
)}
<div className="flex-1 min-w-0">
<p className="font-medium text-gray-900 truncate">
{member.user.fullname}
</p>
<p className="text-xs text-gray-500">
{member.role === ETeamMemberRole.LEADER ? 'Leader' : 'Member'}
</p>
</div>
</div>
))}
</div>
</div>
</div>
</div>
</div>
{/* Invite Member Modal */}
{showInviteModal && (
<div className="fixed inset-0 bg-black/20 backdrop-blur-sm flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-lg shadow-xl max-w-md w-full p-6">
<h2 className="text-2xl font-bold text-gray-900 mb-4">
Invite Team Member
</h2>
<p className="text-gray-600 mb-4">
Send an invitation to join your team. The invited member will see the invitation on their dashboard after logging in.
</p>
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-3 mb-6">
<p className="text-sm text-yellow-800">
<strong>Important:</strong> The email you enter must match the GitHub email address the member uses to sign in.
</p>
</div>
<form onSubmit={handleInviteMember} className="space-y-4">
<div>
<label htmlFor="invite-email" className="block text-sm font-medium text-gray-700 mb-2">
Email Address
</label>
<input
id="invite-email"
type="email"
value={inviteEmail}
onChange={(e) => setInviteEmail(e.target.value)}
placeholder="Enter email address..."
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
required
/>
<p className="text-sm text-gray-500 mt-1">
Current members: {members.length}/{MAX_TEAM_MEMBERS}
</p>
</div>
<div className="flex space-x-3">
<Button
type="button"
variant="secondary"
className="flex-1"
onClick={() => {
setShowInviteModal(false);
setInviteEmail('');
}}
>
Cancel
</Button>
<Button
type="submit"
className="flex-1"
disabled={!inviteEmail.trim() || isInviting}
>
{isInviting ? 'Sending...' : 'Send Invitation'}
</Button>
</div>
</form>
</div>
</div>
)}
{/* Join Requests Modal */}
{showJoinRequestsModal && (
<div className="fixed inset-0 bg-black/30 backdrop-blur-sm flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-lg shadow-xl max-w-2xl w-full p-6 max-h-[80vh] overflow-y-auto">
<div className="flex items-center justify-between mb-6">
<h2 className="text-2xl font-bold text-gray-900">
Join Requests ({pendingJoinRequests.length})
</h2>
<button
onClick={() => setShowJoinRequestsModal(false)}
className="text-gray-400 hover:text-gray-600 text-2xl"
>
</button>
</div>
{pendingJoinRequests.length === 0 ? (
<div className="text-center py-12">
<p className="text-gray-600 text-lg">No pending join requests</p>
<p className="text-gray-500 text-sm mt-2">
When users request to join your team, they'll appear here
</p>
</div>
) : (
<div className="space-y-4">
{pendingJoinRequests.map((request: any) => (
<div key={request.id} className="border border-gray-200 rounded-lg p-4 hover:shadow-md transition-shadow">
<div className="flex items-start justify-between">
<div className="flex items-start space-x-3 flex-1">
{request.user?.avatar ? (
<img
src={request.user.avatar}
alt={request.user.fullname}
className="w-12 h-12 rounded-full object-cover"
/>
) : (
<div className="w-12 h-12 rounded-full bg-gray-200 flex items-center justify-center shrink-0">
<span className="text-gray-500 text-xl">👤</span>
</div>
)}
<div className="flex-1">
<p className="font-semibold text-gray-900">
{request.user?.fullname || 'Unknown User'}
</p>
<p className="text-sm text-gray-600">
{request.user?.email}
</p>
{request.message && (
<div className="mt-2 bg-gray-50 rounded-lg p-3">
<p className="text-sm text-gray-700">
<strong>Message:</strong> {request.message}
</p>
</div>
)}
<p className="text-xs text-gray-500 mt-2">
Requested {new Date(request.created_at).toLocaleDateString()}
</p>
</div>
</div>
<div className="flex space-x-2 ml-4">
<Button
onClick={() => handleRespondToJoinRequest(request.id, 'approve')}
disabled={isResponding || members.length >= MAX_TEAM_MEMBERS}
className="px-4 py-2 text-sm"
>
✓ Accept
</Button>
<Button
onClick={() => handleRespondToJoinRequest(request.id, 'reject')}
disabled={isResponding}
variant="secondary"
className="px-4 py-2 text-sm"
>
Reject
</Button>
</div>
</div>
{members.length >= MAX_TEAM_MEMBERS && (
<div className="mt-3 bg-yellow-50 border border-yellow-200 rounded-lg p-2">
<p className="text-xs text-yellow-800">
Team is full ({MAX_TEAM_MEMBERS}/{MAX_TEAM_MEMBERS} members). Remove a member before accepting new requests.
</p>
</div>
)}
</div>
))}
</div>
)}
</div>
</div>
)}
</div>
);
};
export default TeamDashboardPage;
@@ -1,207 +0,0 @@
import { FC, ReactElement } from 'react';
import { Button } from '@imphnen-frontend-service/ui/atoms';
import { useNavigate, useParams } from 'react-router';
import { useTeamById, useTeamSubmission } from '@imphnen-frontend-service/service';
const SubmissionViewPage: FC = (): ReactElement => {
const { teamId } = useParams<{ teamId: string }>();
const navigate = useNavigate();
const { data: teamData } = useTeamById(teamId || '');
const { data: submissionData, isLoading } = useTeamSubmission(teamId || '', !!teamId);
const team = teamData?.data;
const submission = submissionData?.data;
if (isLoading) {
return (
<div className="flex items-center justify-center min-h-screen">
<div className="text-gray-600">Loading submission...</div>
</div>
);
}
if (!submission) {
return (
<div className="flex flex-col items-center justify-center min-h-screen">
<div className="text-6xl mb-4">📄</div>
<h2 className="text-2xl font-bold text-gray-900 mb-4">No Submission Yet</h2>
<p className="text-gray-600 mb-4">Your team hasn't submitted a project</p>
<Button onClick={() => navigate(`/teams/${teamId}`)}>Back to Team</Button>
</div>
);
}
const submittedDate = submission.submitted_at
? new Date(submission.submitted_at).toLocaleString('id-ID', {
day: 'numeric',
month: 'long',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
})
: 'Not submitted';
return (
<div className="min-h-screen bg-gray-50">
<div className="bg-white border-b">
<div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold text-gray-900">Project Submission</h1>
<p className="text-gray-600 mt-1">{team?.name}</p>
</div>
<Button variant="secondary" onClick={() => navigate(`/teams/${teamId}`)}>
Back to Team
</Button>
</div>
</div>
</div>
<div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{/* Status Banner */}
<div className="bg-green-50 border border-green-500 rounded-lg p-6 mb-6">
<div className="flex items-center space-x-3">
<span className="text-4xl">✅</span>
<div>
<h3 className="font-bold text-green-900 text-lg">Project Submitted Successfully</h3>
<p className="text-green-700 text-sm">
Submitted on {submittedDate}
</p>
<p className="text-green-600 text-xs mt-1">
This submission is now read-only and cannot be edited
</p>
</div>
</div>
</div>
<div className="bg-white rounded-lg shadow-md overflow-hidden">
{/* Project Header */}
<div className="bg-gradient-to-r from-blue-600 to-blue-800 text-white p-8">
<h2 className="text-3xl font-bold mb-2">{submission.project_name}</h2>
<p className="text-blue-100">Team: {team?.name}</p>
</div>
{/* Project Details */}
<div className="p-8 space-y-6">
{/* Description */}
<div>
<h3 className="text-lg font-bold text-gray-900 mb-3">Project Description</h3>
<div className="bg-gray-50 rounded-lg p-4">
<p className="text-gray-700 whitespace-pre-wrap">{submission.description}</p>
</div>
</div>
{/* Links */}
<div className="grid gap-6 md:grid-cols-2">
{/* Repository */}
<div>
<h3 className="text-lg font-bold text-gray-900 mb-3">Repository</h3>
<a
href={submission.repository_url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center space-x-2 text-blue-600 hover:text-blue-800"
>
<span>🔗</span>
<span className="break-all">{submission.repository_url}</span>
</a>
</div>
{/* Demo URL */}
{submission.demo_url && (
<div>
<h3 className="text-lg font-bold text-gray-900 mb-3">Live Demo</h3>
<a
href={submission.demo_url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center space-x-2 text-blue-600 hover:text-blue-800"
>
<span>🌐</span>
<span className="break-all">{submission.demo_url}</span>
</a>
</div>
)}
{/* Presentation URL */}
{submission.presentation_url && (
<div>
<h3 className="text-lg font-bold text-gray-900 mb-3">Presentation</h3>
<a
href={submission.presentation_url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center space-x-2 text-blue-600 hover:text-blue-800"
>
<span>📊</span>
<span className="break-all">{submission.presentation_url}</span>
</a>
</div>
)}
</div>
{/* Screenshots */}
{submission.screenshots && submission.screenshots.length > 0 && (
<div>
<h3 className="text-lg font-bold text-gray-900 mb-3">
Screenshots ({submission.screenshots.length})
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{submission.screenshots.map((url, index) => (
<a
key={index}
href={url}
target="_blank"
rel="noopener noreferrer"
className="block"
>
<img
src={url}
alt={`Screenshot ${index + 1}`}
className="w-full h-48 object-cover rounded-lg border-2 border-gray-200 hover:border-blue-500 transition-colors cursor-pointer"
/>
</a>
))}
</div>
</div>
)}
{/* Submission Info */}
<div className="bg-gray-50 rounded-lg p-4 border-t-4 border-blue-600">
<h3 className="text-sm font-bold text-gray-900 mb-2">Submission Information</h3>
<div className="grid gap-2 text-sm">
<div className="flex justify-between">
<span className="text-gray-600">Status:</span>
<span className="font-medium text-green-600">
{submission.status === 'submitted' ? ' Submitted' : 'Draft'}
</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600">Submitted:</span>
<span className="font-medium text-gray-900">{submittedDate}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600">Submission ID:</span>
<span className="font-medium text-gray-900 font-mono text-xs">
{submission.id}
</span>
</div>
</div>
</div>
{/* Read-only Notice */}
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4">
<p className="text-sm text-yellow-800">
<strong>Note:</strong> This submission is now locked and cannot be edited or deleted.
If you need to make changes, please contact the hackathon organizers.
</p>
</div>
</div>
</div>
</div>
</div>
);
};
export default SubmissionViewPage;
@@ -1,296 +0,0 @@
import { FC, ReactElement, useState } from 'react';
import { ControlledInputField } from '@imphnen-frontend-service/ui/organisms';
import { Button, Textarea } from '@imphnen-frontend-service/ui/atoms';
import { useNavigate, useParams } from 'react-router';
import { useForm, Controller } from 'react-hook-form';
import {
projectSubmissionSchema,
TProjectSubmissionForm,
useSubmitProject,
useTeamById,
useTeamSubmission,
useUploadFile,
useAuthStore,
} from '@imphnen-frontend-service/service';
import { zodResolver } from '@hookform/resolvers/zod';
const SubmitProjectPage: FC = (): ReactElement => {
const { teamId } = useParams<{ teamId: string }>();
const navigate = useNavigate();
const { session } = useAuthStore();
const [showConfirmModal, setShowConfirmModal] = useState(false);
const [screenshots, setScreenshots] = useState<string[]>([]);
const { data: teamData } = useTeamById(teamId || '');
const { data: submissionData } = useTeamSubmission(teamId || '', !!teamId);
const { mutateAsync: submitProject, isPending: isSubmitting } = useSubmitProject(teamId || '');
const { mutateAsync: uploadFile, isPending: isUploading } = useUploadFile();
const team = teamData?.data;
const currentUserId = session?.user?.id;
const isLeader = currentUserId === team?.leader_id;
const hasSubmission = !!submissionData?.data;
const form = useForm<TProjectSubmissionForm>({
resolver: zodResolver(projectSubmissionSchema),
mode: 'all',
});
if (!isLeader) {
return (
<div className="flex flex-col items-center justify-center min-h-screen">
<h2 className="text-2xl font-bold text-gray-900 mb-4">Access Denied</h2>
<p className="text-gray-600 mb-4">Only the team leader can submit projects</p>
<Button onClick={() => navigate(`/teams/${teamId}`)}>Back to Team</Button>
</div>
);
}
if (hasSubmission) {
return (
<div className="flex flex-col items-center justify-center min-h-screen">
<div className="text-6xl mb-4"></div>
<h2 className="text-2xl font-bold text-gray-900 mb-4">Project Already Submitted</h2>
<p className="text-gray-600 mb-4">Your team has already submitted a project</p>
<div className="flex space-x-3">
<Button onClick={() => navigate(`/teams/${teamId}/submission`)}>
View Submission
</Button>
<Button variant="secondary" onClick={() => navigate(`/teams/${teamId}`)}>
Back to Team
</Button>
</div>
</div>
);
}
const handleScreenshotUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files;
if (!files) return;
try {
const uploadPromises = Array.from(files).map(file => uploadFile(file));
const results = await Promise.all(uploadPromises);
const urls = results.map(r => r.data.url);
setScreenshots([...screenshots, ...urls]);
} catch (error) {
console.error('Failed to upload screenshots:', error);
}
};
const removeScreenshot = (index: number) => {
setScreenshots(screenshots.filter((_, i) => i !== index));
};
const onSubmit = form.handleSubmit(async (data) => {
try {
await submitProject({
...data,
screenshots,
});
navigate(`/teams/${teamId}/submission`);
} catch (error) {
console.error('Failed to submit project:', error);
}
});
return (
<div className="min-h-screen bg-gray-50">
<div className="bg-white border-b">
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
<h1 className="text-3xl font-bold text-gray-900">Submit Project</h1>
<p className="text-gray-600 mt-1">{team?.name}</p>
</div>
</div>
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{/* Warning Banner */}
<div className="bg-red-50 border-2 border-red-500 rounded-lg p-6 mb-6">
<div className="flex items-start space-x-3">
<span className="text-3xl"></span>
<div>
<h3 className="font-bold text-red-900 text-lg">IMPORTANT WARNING</h3>
<ul className="text-red-800 mt-2 space-y-1 text-sm">
<li> You can only submit your project ONCE</li>
<li> After submission, you CANNOT edit or change anything</li>
<li> Make sure all information is correct before submitting</li>
<li> Review your project details carefully</li>
</ul>
</div>
</div>
</div>
<div className="bg-white rounded-lg shadow-md p-8">
<form onSubmit={(e) => {
e.preventDefault();
setShowConfirmModal(true);
}} className="space-y-6">
{/* Project Name */}
<ControlledInputField
control={form.control}
label="Project Name"
placeholder="Enter your project name"
name="project_name"
/>
{/* Description */}
<div className="space-y-2">
<label className="block text-sm font-medium text-gray-700">
Project Description <span className="text-red-500">*</span>
</label>
<Controller
control={form.control}
name="description"
render={({ field, fieldState }) => (
<div>
<Textarea
{...field}
placeholder="Describe your project, its features, and what problem it solves..."
rows={6}
className="w-full"
/>
{fieldState.error && (
<p className="text-sm text-red-500 mt-1">{fieldState.error.message}</p>
)}
</div>
)}
/>
</div>
{/* Repository URL */}
<ControlledInputField
control={form.control}
label="Repository URL (GitHub, GitLab, etc.)"
placeholder="https://github.com/username/project"
name="repository_url"
type="url"
/>
{/* Demo URL */}
<ControlledInputField
control={form.control}
label="Demo URL (Optional)"
placeholder="https://your-project-demo.com"
name="demo_url"
type="url"
/>
{/* Presentation URL */}
<ControlledInputField
control={form.control}
label="Presentation URL (Optional)"
placeholder="https://slides.com/your-presentation or Google Drive link"
name="presentation_url"
type="url"
/>
{/* Screenshots */}
<div className="space-y-2">
<label className="block text-sm font-medium text-gray-700">
Project Screenshots <span className="text-gray-400">(Optional)</span>
</label>
<div className="grid grid-cols-2 md:grid-cols-3 gap-4 mb-4">
{screenshots.map((url, index) => (
<div key={index} className="relative">
<img
src={url}
alt={`Screenshot ${index + 1}`}
className="w-full h-32 object-cover rounded-lg border-2 border-gray-200"
/>
<button
type="button"
onClick={() => removeScreenshot(index)}
className="absolute top-1 right-1 bg-red-500 text-white rounded-full w-6 h-6 flex items-center justify-center text-sm hover:bg-red-600"
>
×
</button>
</div>
))}
</div>
<label className="flex flex-col items-center justify-center w-full h-32 border-2 border-dashed border-gray-300 rounded-lg cursor-pointer hover:bg-gray-50">
<div className="text-center">
<p className="text-gray-500">Click to upload screenshots</p>
<p className="text-xs text-gray-400 mt-1">PNG, JPG up to 5MB each</p>
</div>
<input
type="file"
accept="image/*"
multiple
className="hidden"
onChange={handleScreenshotUpload}
disabled={isUploading}
/>
</label>
</div>
{/* Submit Button */}
<div className="flex space-x-3 pt-4">
<Button
type="button"
variant="secondary"
className="flex-1"
onClick={() => navigate(`/teams/${teamId}`)}
>
Cancel
</Button>
<Button
type="submit"
className="flex-1"
disabled={!form.formState.isValid || isUploading}
>
Review & Submit
</Button>
</div>
</form>
</div>
</div>
{/* Confirmation Modal */}
{showConfirmModal && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-lg shadow-xl max-w-md w-full p-6">
<div className="text-center mb-6">
<div className="text-5xl mb-4"></div>
<h2 className="text-2xl font-bold text-gray-900 mb-2">
Final Confirmation
</h2>
<p className="text-red-600 font-medium">
This action is IRREVERSIBLE!
</p>
</div>
<div className="bg-gray-50 rounded-lg p-4 mb-6">
<p className="text-sm text-gray-700 mb-3">
By clicking "Submit Project", you confirm that:
</p>
<ul className="text-sm text-gray-600 space-y-2">
<li> All information is correct and complete</li>
<li> You understand this can only be done once</li>
<li> You cannot edit after submission</li>
<li> Your team agrees with this submission</li>
</ul>
</div>
<div className="flex space-x-3">
<Button
type="button"
variant="secondary"
className="flex-1"
onClick={() => setShowConfirmModal(false)}
>
Go Back
</Button>
<Button
onClick={onSubmit}
className="flex-1 bg-red-600 hover:bg-red-700"
disabled={isSubmitting}
>
{isSubmitting ? 'Submitting...' : 'Submit Project'}
</Button>
</div>
</div>
</div>
)}
</div>
);
};
export default SubmitProjectPage;
@@ -1,230 +0,0 @@
import { FC, ReactElement, useState } from 'react';
import { Button, Input } from '@imphnen-frontend-service/ui/atoms';
import { Link, useNavigate } from 'react-router';
import { useTeams, useJoinTeam, useMyTeams, ETeamVisibility, joinTeamSchema, TJoinTeamForm } from '@imphnen-frontend-service/service';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import INDONESIAN_CITIES_DATA from '../../../constants/cities';
const INDONESIAN_CITIES = ['All Cities', ...INDONESIAN_CITIES_DATA];
const BrowseTeamsPage: FC = (): ReactElement => {
const navigate = useNavigate();
const [search, setSearch] = useState('');
const [selectedCity, setSelectedCity] = useState('All Cities');
const [selectedTeamId, setSelectedTeamId] = useState<string | null>(null);
const [showJoinModal, setShowJoinModal] = useState(false);
const { data: teamsData, isLoading } = useTeams({
search,
city: selectedCity === 'All Cities' ? undefined : selectedCity,
visibility: ETeamVisibility.PUBLIC,
});
const { data: myTeamsData } = useMyTeams();
const { mutateAsync: joinTeam, isPending: isJoining } = useJoinTeam();
const form = useForm<TJoinTeamForm>({
resolver: zodResolver(joinTeamSchema),
mode: 'all',
});
const teams = teamsData?.data || [];
const myTeams = myTeamsData?.data || [];
// Helper function to check if user is a member of a team
const isMyTeam = (teamId: string) => {
return myTeams.some((team: any) => team.id === teamId);
};
const handleJoinRequest = (teamId: string) => {
setSelectedTeamId(teamId);
setShowJoinModal(true);
};
const onSubmit = form.handleSubmit(async (data) => {
if (!selectedTeamId) return;
try {
await joinTeam({ teamId: selectedTeamId, data });
setShowJoinModal(false);
form.reset();
setSelectedTeamId(null);
} catch (error) {
console.error('Failed to send join request:', error);
}
});
return (
<div className="min-h-screen bg-gray-50">
{/* Header */}
<div className="bg-white border-b">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold text-gray-900">Browse Teams</h1>
<p className="text-gray-600 mt-1">Find and join teams looking for members</p>
</div>
<Link to="/dashboard">
<Button variant="secondary">Back to Dashboard</Button>
</Link>
</div>
</div>
</div>
{/* Filters */}
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
<div className="bg-white p-6 rounded-lg shadow-sm mb-6">
<div className="grid gap-4 md:grid-cols-2">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Search Teams
</label>
<Input
type="text"
placeholder="Search by team name..."
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Filter by City
</label>
<select
value={selectedCity}
onChange={(e) => setSelectedCity(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
>
{INDONESIAN_CITIES.map((city) => (
<option key={city} value={city}>
{city}
</option>
))}
</select>
</div>
</div>
</div>
{/* Teams List */}
{isLoading ? (
<div className="text-center py-12">
<p className="text-gray-600">Loading teams...</p>
</div>
) : teams.length === 0 ? (
<div className="bg-white rounded-lg shadow-sm p-12 text-center">
<p className="text-gray-600 text-lg">No teams found</p>
<p className="text-gray-500 mt-2">Try adjusting your filters</p>
</div>
) : (
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
{teams.map((team) => (
<div key={team.id} className="bg-white rounded-lg shadow-md overflow-hidden hover:shadow-lg transition-shadow">
{team.banner && (
<img
src={team.banner}
alt={team.name}
className="w-full h-32 object-cover"
/>
)}
<div className="p-6">
<div className="flex items-center space-x-3 mb-3">
{team.logo ? (
<img
src={team.logo}
alt={team.name}
className="w-12 h-12 rounded-full object-cover"
/>
) : (
<div className="w-12 h-12 rounded-full bg-gray-200 flex items-center justify-center">
<span className="text-gray-500 text-xl">👥</span>
</div>
)}
<div className="flex-1">
<h3 className="text-lg font-bold text-gray-900">{team.name}</h3>
<p className="text-sm text-gray-600">📍 {team.city}</p>
</div>
</div>
<p className="text-gray-600 text-sm mb-4 line-clamp-3">
{team.description}
</p>
{isMyTeam(team.id) ? (
<Button
className="w-full"
variant="secondary"
onClick={() => navigate(`/teams/${team.id}`)}
>
Your Team
</Button>
) : (
<Button
className="w-full"
onClick={() => handleJoinRequest(team.id)}
>
Request to Join
</Button>
)}
</div>
</div>
))}
</div>
)}
</div>
{/* Join Request Modal */}
{showJoinModal && (
<div className="fixed inset-0 bg-black/30 backdrop-blur-sm flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-lg shadow-xl max-w-md w-full p-6">
<h2 className="text-2xl font-bold text-gray-900 mb-4">
Request to Join Team
</h2>
<p className="text-gray-600 mb-6">
Send a message to the team leader explaining why you want to join
</p>
<form onSubmit={onSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Your Message
</label>
<textarea
{...form.register('message')}
rows={4}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="Tell the team leader why you want to join their team..."
/>
{form.formState.errors.message && (
<p className="text-sm text-red-500 mt-1">
{form.formState.errors.message.message}
</p>
)}
</div>
<div className="flex space-x-3">
<Button
type="button"
variant="secondary"
className="flex-1"
onClick={() => {
setShowJoinModal(false);
form.reset();
setSelectedTeamId(null);
}}
>
Cancel
</Button>
<Button
type="submit"
className="flex-1"
disabled={!form.formState.isValid || isJoining}
>
{isJoining ? 'Sending...' : 'Send Request'}
</Button>
</div>
</form>
</div>
</div>
)}
</div>
);
};
export default BrowseTeamsPage;
@@ -1,317 +0,0 @@
import { FC, ReactElement, useState } from 'react';
import { ControlledInputField } from '@imphnen-frontend-service/ui/organisms';
import { Button, Textarea } from '@imphnen-frontend-service/ui/atoms';
import { useNavigate } from 'react-router';
import { useForm, Controller } from 'react-hook-form';
import { teamCreateSchema, TTeamCreateForm, useCreateTeam, ETeamVisibility, useUploadFile } from '@imphnen-frontend-service/service';
import { zodResolver } from '@hookform/resolvers/zod';
import INDONESIAN_CITIES from '../../../constants/cities';
const CreateTeamPage: FC = (): ReactElement => {
const navigate = useNavigate();
const [logoFile, setLogoFile] = useState<File | null>(null);
const [logoPreview, setLogoPreview] = useState<string>('');
const [bannerFile, setBannerFile] = useState<File | null>(null);
const [bannerPreview, setBannerPreview] = useState<string>('');
const form = useForm<TTeamCreateForm>({
resolver: zodResolver(teamCreateSchema),
mode: 'all',
defaultValues: {
visibility: ETeamVisibility.PUBLIC,
logo: null,
banner: null,
},
});
const { mutateAsync: createTeam, isPending: isCreating } = useCreateTeam();
const { mutateAsync: uploadFile, isPending: isUploading } = useUploadFile();
const handleLogoChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
setLogoFile(file);
const reader = new FileReader();
reader.onloadend = () => {
setLogoPreview(reader.result as string);
};
reader.readAsDataURL(file);
}
};
const handleBannerChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
setBannerFile(file);
const reader = new FileReader();
reader.onloadend = () => {
setBannerPreview(reader.result as string);
};
reader.readAsDataURL(file);
}
};
const onSubmit = form.handleSubmit(async (data) => {
try {
let logoUrl = null;
let bannerUrl = null;
if (logoFile) {
const logoResult = await uploadFile(logoFile);
logoUrl = logoResult.data.url;
}
if (bannerFile) {
const bannerResult = await uploadFile(bannerFile);
bannerUrl = bannerResult.data.url;
}
const result = await createTeam({
...data,
logo: logoUrl,
banner: bannerUrl,
});
navigate(`/teams/${result.data.id}`);
} catch (error) {
console.error('Failed to create team:', error);
}
});
return (
<div className="min-h-screen bg-gray-50">
{/* Header */}
<div className="bg-white border-b">
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
<h1 className="text-3xl font-bold text-gray-900">Create Your Team</h1>
<p className="text-gray-600 mt-1">Build your hackathon dream team</p>
</div>
</div>
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div className="bg-white rounded-lg shadow-md p-8">
<form onSubmit={onSubmit} className="space-y-6">
{/* Banner Upload */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Team Banner <span className="text-gray-400">(Optional)</span>
</label>
{bannerPreview ? (
<div className="relative">
<img
src={bannerPreview}
alt="Banner preview"
className="w-full h-48 object-cover rounded-lg border-2 border-gray-200"
/>
<button
type="button"
onClick={() => {
setBannerFile(null);
setBannerPreview('');
}}
className="absolute top-2 right-2 bg-red-500 text-white px-3 py-1 rounded-lg text-sm hover:bg-red-600"
>
Remove
</button>
</div>
) : (
<label className="flex flex-col items-center justify-center w-full h-48 border-2 border-dashed border-gray-300 rounded-lg cursor-pointer hover:bg-gray-50">
<div className="text-center">
<p className="text-gray-500">Click to upload banner</p>
<p className="text-xs text-gray-400 mt-1">1200x400 recommended</p>
</div>
<input
type="file"
accept="image/*"
className="hidden"
onChange={handleBannerChange}
/>
</label>
)}
</div>
{/* Logo Upload */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Team Logo <span className="text-gray-400">(Optional, but highly recommended)</span>
</label>
<div className="flex items-center space-x-4">
{logoPreview ? (
<img
src={logoPreview}
alt="Logo preview"
className="w-24 h-24 rounded-full object-cover border-2 border-gray-200"
/>
) : (
<div className="w-24 h-24 rounded-full bg-gray-200 flex items-center justify-center">
<span className="text-gray-400 text-3xl">👥</span>
</div>
)}
<div>
<label htmlFor="logo" className="cursor-pointer">
<span className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 inline-block">
{logoPreview ? 'Change Logo' : 'Upload Logo'}
</span>
<input
id="logo"
type="file"
accept="image/*"
className="hidden"
onChange={handleLogoChange}
/>
</label>
{logoPreview && (
<button
type="button"
onClick={() => {
setLogoFile(null);
setLogoPreview('');
}}
className="ml-3 px-4 py-2 bg-red-500 text-white rounded-lg hover:bg-red-600"
>
Remove
</button>
)}
</div>
</div>
</div>
{/* Team Name */}
<ControlledInputField
control={form.control}
label="Team Name"
placeholder="Enter your team name"
name="name"
/>
{/* City */}
<div className="space-y-2">
<label className="block text-sm font-medium text-gray-700">
City <span className="text-red-500">*</span>
</label>
<Controller
control={form.control}
name="city"
render={({ field, fieldState }) => (
<div>
<select
{...field}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
>
<option value="">Select city</option>
{INDONESIAN_CITIES.map((city) => (
<option key={city} value={city}>
{city}
</option>
))}
</select>
{fieldState.error && (
<p className="text-sm text-red-500 mt-1">{fieldState.error.message}</p>
)}
</div>
)}
/>
</div>
{/* Description */}
<div className="space-y-2">
<label className="block text-sm font-medium text-gray-700">
Description <span className="text-red-500">*</span>
</label>
<Controller
control={form.control}
name="description"
render={({ field, fieldState }) => (
<div>
<Textarea
{...field}
placeholder="Tell others about your team, what you're looking for, your goals..."
rows={4}
className="w-full"
/>
{fieldState.error && (
<p className="text-sm text-red-500 mt-1">{fieldState.error.message}</p>
)}
</div>
)}
/>
</div>
{/* Visibility */}
<div className="space-y-2">
<label className="block text-sm font-medium text-gray-700">
Team Visibility <span className="text-red-500">*</span>
</label>
<Controller
control={form.control}
name="visibility"
render={({ field }) => (
<div className="space-y-3">
<label className="flex items-start space-x-3 cursor-pointer border rounded-lg p-4 hover:bg-gray-50">
<input
type="radio"
{...field}
value={ETeamVisibility.PUBLIC}
checked={field.value === ETeamVisibility.PUBLIC}
className="mt-1"
/>
<div>
<p className="font-medium text-gray-900">Public</p>
<p className="text-sm text-gray-600">
Team will be visible in Browse Teams. Anyone can request to join.
</p>
</div>
</label>
<label className="flex items-start space-x-3 cursor-pointer border rounded-lg p-4 hover:bg-gray-50">
<input
type="radio"
{...field}
value={ETeamVisibility.PRIVATE}
checked={field.value === ETeamVisibility.PRIVATE}
className="mt-1"
/>
<div>
<p className="font-medium text-gray-900">Private</p>
<p className="text-sm text-gray-600">
Team is hidden from Browse Teams. Members can only join via invitation.
</p>
</div>
</label>
</div>
)}
/>
</div>
{/* Warning */}
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4">
<p className="text-sm text-yellow-800">
<strong>Note:</strong> As team leader, you cannot leave or join another team after creating this team.
</p>
</div>
{/* Submit Button */}
<div className="flex space-x-3 pt-4">
<Button
type="button"
variant="secondary"
className="flex-1"
onClick={() => navigate('/dashboard')}
>
Cancel
</Button>
<Button
type="submit"
className="flex-1"
disabled={!form.formState.isValid || isCreating || isUploading}
>
{isCreating || isUploading ? 'Creating Team...' : 'Create Team'}
</Button>
</div>
</form>
</div>
</div>
</div>
);
};
export default CreateTeamPage;
@@ -1,144 +0,0 @@
import { FC, useState } from 'react';
import { Link, useNavigate } from 'react-router';
import { useUserMe, useMyTeams } from '@imphnen-frontend-service/service';
import { useSession } from '@imphnen-frontend-service/utils';
export const Navigation: FC = () => {
const navigate = useNavigate();
const [showUserMenu, setShowUserMenu] = useState(false);
const { data: userData } = useUserMe();
const { data: teamsData } = useMyTeams();
const { signOut } = useSession();
const user = userData?.data;
const myTeams = teamsData?.data || [];
const hasTeam = myTeams.length > 0;
const handleLogout = async () => {
await signOut();
navigate('/auth/login');
};
return (
<nav className="bg-white border-b shadow-sm">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex justify-between items-center h-16">
{/* Logo */}
<Link to="/dashboard" className="flex items-center space-x-2">
<span className="text-2xl">🏆</span>
<span className="text-xl font-bold text-gray-900">Hackathon</span>
</Link>
{/* Navigation Links */}
<div className="hidden md:flex items-center space-x-6">
<Link
to="/dashboard"
className="text-gray-700 hover:text-blue-600 font-medium transition-colors"
>
Dashboard
</Link>
<Link
to="/teams/browse"
className="text-gray-700 hover:text-blue-600 font-medium transition-colors"
>
Browse Teams
</Link>
{hasTeam && (
<Link
to={`/teams/${myTeams[0].id}`}
className="text-gray-700 hover:text-blue-600 font-medium transition-colors"
>
My Team
</Link>
)}
</div>
{/* User Menu */}
<div className="relative">
<button
onClick={() => setShowUserMenu(!showUserMenu)}
className="flex items-center space-x-3 focus:outline-none"
>
{user?.avatar ? (
<img
src={user.avatar}
alt={user.fullname}
className="w-10 h-10 rounded-full object-cover border-2 border-gray-200"
/>
) : (
<div className="w-10 h-10 rounded-full bg-gray-200 flex items-center justify-center">
<span className="text-gray-500">👤</span>
</div>
)}
<span className="hidden md:block text-gray-700 font-medium">
{user?.fullname}
</span>
<svg
className="w-4 h-4 text-gray-500"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 9l-7 7-7-7"
/>
</svg>
</button>
{/* Dropdown Menu */}
{showUserMenu && (
<div className="absolute right-0 mt-2 w-48 bg-white rounded-lg shadow-lg border border-gray-200 py-2 z-50">
<div className="px-4 py-2 border-b border-gray-200">
<p className="text-sm font-medium text-gray-900">{user?.fullname}</p>
<p className="text-xs text-gray-600 truncate">{user?.email}</p>
</div>
<Link
to="/dashboard"
className="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
onClick={() => setShowUserMenu(false)}
>
Dashboard
</Link>
<Link
to="/teams/browse"
className="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
onClick={() => setShowUserMenu(false)}
>
Browse Teams
</Link>
{hasTeam && (
<Link
to={`/teams/${myTeams[0].id}`}
className="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
onClick={() => setShowUserMenu(false)}
>
My Team
</Link>
)}
<div className="border-t border-gray-200 mt-2 pt-2">
<button
onClick={handleLogout}
className="block w-full text-left px-4 py-2 text-sm text-red-600 hover:bg-red-50"
>
Logout
</button>
</div>
</div>
)}
</div>
</div>
</div>
{/* Close dropdown when clicking outside */}
{showUserMenu && (
<div
className="fixed inset-0 z-40"
onClick={() => setShowUserMenu(false)}
/>
)}
</nav>
);
};
-518
View File
@@ -1,518 +0,0 @@
const INDONESIAN_CITIES: string[] = [
'Aceh Selatan',
'Aceh Tenggara',
'Aceh Timur',
'Aceh Tengah',
'Aceh Barat',
'Aceh Besar',
'Pidie',
'Aceh Utara',
'Simeulue',
'Aceh Singkil',
'Bireuen',
'Aceh Barat Daya',
'Gayo Lues',
'Aceh Jaya',
'Nagan Raya',
'Aceh Tamiang',
'Bener Meriah',
'Pidie Jaya',
'Kota Banda Aceh',
'Kota Sabang',
'Kota Lhokseumawe',
'Kota Langsa',
'Kota Subulussalam',
'Tapanuli Tengah',
'Tapanuli Utara',
'Tapanuli Selatan',
'Nias',
'Langkat',
'Karo',
'Deli Serdang',
'Simalungun',
'Asahan',
'Labuhanbatu',
'Dairi',
'Toba',
'Mandailing Natal',
'Nias Selatan',
'Pakpak Bharat',
'Humbang Hasundutan',
'Samosir',
'Serdang Bedagai',
'Batu Bara',
'Padang Lawas Utara',
'Padang Lawas',
'Labuhanbatu Selatan',
'Labuhanbatu Utara',
'Nias Utara',
'Nias Barat',
'Kota Medan',
'Kota Pematangsiantar',
'Kota Sibolga',
'Kota Tanjung Balai',
'Kota Binjai',
'Kota Tebing Tinggi',
'Kota Padangsidimpuan',
'Kota Gunungsitoli',
'Pesisir Selatan',
'Solok',
'Sijunjung',
'Tanah Datar',
'Padang Pariaman',
'Agam',
'Lima Puluh Kota',
'Pasaman',
'Kepulauan Mentawai',
'Dharmasraya',
'Solok Selatan',
'Pasaman Barat',
'Kota Padang',
'Kota Solok',
'Kota Sawahlunto',
'Kota Padang Panjang',
'Kota Bukittinggi',
'Kota Payakumbuh',
'Kota Pariaman',
'Kampar',
'Indragiri Hulu',
'Bengkalis',
'Indragiri Hilir',
'Pelalawan',
'Rokan Hulu',
'Rokan Hilir',
'Siak',
'Kuantan Singingi',
'Kepulauan Meranti',
'Kota Pekanbaru',
'Kota Dumai',
'Kerinci',
'Merangin',
'Sarolangun',
'Batanghari',
'Muaro Jambi',
'Tanjung Jabung Barat',
'Tanjung Jabung Timur',
'Bungo',
'Tebo',
'Kota Jambi',
'Kota Sungai Penuh',
'Ogan Komering Ulu',
'Ogan Komering Ilir',
'Muara Enim',
'Lahat',
'Musi Rawas',
'Musi Banyuasin',
'Banyuasin',
'Ogan Komering Ulu Timur',
'Ogan Komering Ulu Selatan',
'Ogan Ilir',
'Empat Lawang',
'Penukal Abab Lematang Ilir',
'Musi Rawas Utara',
'Kota Palembang',
'Kota Pagar Alam',
'Kota Lubuk Linggau',
'Kota Prabumulih',
'Bengkulu Selatan',
'Rejang Lebong',
'Bengkulu Utara',
'Kaur',
'Seluma',
'Muko Muko',
'Lebong',
'Kepahiang',
'Bengkulu Tengah',
'Kota Bengkulu',
'Lampung Selatan',
'Lampung Tengah',
'Lampung Utara',
'Lampung Barat',
'Tulang Bawang',
'Tanggamus',
'Lampung Timur',
'Way Kanan',
'Pesawaran',
'Pringsewu',
'Mesuji',
'Tulang Bawang Barat',
'Pesisir Barat',
'Kota Bandar Lampung',
'Kota Metro',
'Bangka',
'Belitung',
'Bangka Selatan',
'Bangka Tengah',
'Bangka Barat',
'Belitung Timur',
'Kota Pangkal Pinang',
'Bintan',
'Karimun',
'Natuna',
'Lingga',
'Kepulauan Anambas',
'Kota Batam',
'Kota Tanjung Pinang',
'Kepulauan Seribu',
'Kota Jakarta Pusat',
'Kota Jakarta Utara',
'Kota Jakarta Barat',
'Kota Jakarta Selatan',
'Kota Jakarta Timur',
'Bogor',
'Sukabumi',
'Cianjur',
'Bandung',
'Garut',
'Tasikmalaya',
'Ciamis',
'Kuningan',
'Cirebon',
'Majalengka',
'Sumedang',
'Indramayu',
'Subang',
'Purwakarta',
'Karawang',
'Bekasi',
'Bandung Barat',
'Pangandaran',
'Kota Bogor',
'Kota Sukabumi',
'Kota Bandung',
'Kota Cirebon',
'Kota Bekasi',
'Kota Depok',
'Kota Cimahi',
'Kota Tasikmalaya',
'Kota Banjar',
'Cilacap',
'Banyumas',
'Purbalingga',
'Banjarnegara',
'Kebumen',
'Purworejo',
'Wonosobo',
'Magelang',
'Boyolali',
'Klaten',
'Sukoharjo',
'Wonogiri',
'Karanganyar',
'Sragen',
'Grobogan',
'Blora',
'Rembang',
'Pati',
'Kudus',
'Jepara',
'Demak',
'Semarang',
'Temanggung',
'Kendal',
'Batang',
'Pekalongan',
'Pemalang',
'Tegal',
'Brebes',
'Kota Magelang',
'Kota Surakarta',
'Kota Salatiga',
'Kota Semarang',
'Kota Pekalongan',
'Kota Tegal',
'Kulon Progo',
'Bantul',
'Gunungkidul',
'Sleman',
'Kota Yogyakarta',
'Pacitan',
'Ponorogo',
'Trenggalek',
'Tulungagung',
'Blitar',
'Kediri',
'Malang',
'Lumajang',
'Jember',
'Banyuwangi',
'Bondowoso',
'Situbondo',
'Probolinggo',
'Pasuruan',
'Sidoarjo',
'Mojokerto',
'Jombang',
'Nganjuk',
'Madiun',
'Magetan',
'Ngawi',
'Bojonegoro',
'Tuban',
'Lamongan',
'Gresik',
'Bangkalan',
'Sampang',
'Pamekasan',
'Sumenep',
'Kota Kediri',
'Kota Blitar',
'Kota Malang',
'Kota Probolinggo',
'Kota Pasuruan',
'Kota Mojokerto',
'Kota Madiun',
'Kota Surabaya',
'Kota Batu',
'Pandeglang',
'Lebak',
'Tangerang',
'Serang',
'Kota Tangerang',
'Kota Cilegon',
'Kota Serang',
'Kota Tangerang Selatan',
'Jembrana',
'Tabanan',
'Badung',
'Gianyar',
'Klungkung',
'Bangli',
'Karangasem',
'Buleleng',
'Kota Denpasar',
'Lombok Barat',
'Lombok Tengah',
'Lombok Timur',
'Sumbawa',
'Dompu',
'Bima',
'Sumbawa Barat',
'Lombok Utara',
'Kota Mataram',
'Kota Bima',
'Kupang',
'Timor Tengah Selatan',
'Timor Tengah Utara',
'Belu',
'Alor',
'Flores Timur',
'Sikka',
'Ende',
'Ngada',
'Manggarai',
'Sumba Timur',
'Sumba Barat',
'Lembata',
'Rote Ndao',
'Manggarai Barat',
'Nagekeo',
'Sumba Tengah',
'Sumba Barat Daya',
'Manggarai Timur',
'Sabu Raijua',
'Malaka',
'Kota Kupang',
'Sambas',
'Mempawah',
'Sanggau',
'Ketapang',
'Sintang',
'Kapuas Hulu',
'Bengkayang',
'Landak',
'Sekadau',
'Melawi',
'Kayong Utara',
'Kubu Raya',
'Kota Pontianak',
'Kota Singkawang',
'Kotawaringin Barat',
'Kotawaringin Timur',
'Kapuas',
'Barito Selatan',
'Barito Utara',
'Katingan',
'Seruyan',
'Sukamara',
'Lamandau',
'Gunung Mas',
'Pulang Pisau',
'Murung Raya',
'Barito Timur',
'Kota Palangkaraya',
'Tanah Laut',
'Kotabaru',
'Banjar',
'Barito Kuala',
'Tapin',
'Hulu Sungai Selatan',
'Hulu Sungai Tengah',
'Hulu Sungai Utara',
'Tabalong',
'Tanah Bumbu',
'Balangan',
'Kota Banjarmasin',
'Kota Banjarbaru',
'Paser',
'Kutai Kartanegara',
'Berau',
'Kutai Barat',
'Kutai Timur',
'Penajam Paser Utara',
'Mahakam Ulu',
'Kota Balikpapan',
'Kota Samarinda',
'Kota Bontang',
'Bulungan',
'Malinau',
'Nunukan',
'Tana Tidung',
'Kota Tarakan',
'Bolaang Mongondow',
'Minahasa',
'Kepulauan Sangihe',
'Kepulauan Talaud',
'Minahasa Selatan',
'Minahasa Utara',
'Minahasa Tenggara',
'Bolaang Mongondow Utara',
'Kepulauan Siau Tagulandang Biaro (Sitaro)',
'Bolaang Mongondow Timur',
'Bolaang Mongondow Selatan',
'Kota Manado',
'Kota Bitung',
'Kota Tomohon',
'Kota Kotamobagu',
'Banggai',
'Poso',
'Donggala',
'Toli Toli',
'Buol',
'Morowali',
'Banggai Kepulauan',
'Parigi Moutong',
'Tojo Una Una',
'Sigi',
'Banggai Laut',
'Morowali Utara',
'Kota Palu',
'Kepulauan Selayar',
'Bulukumba',
'Bantaeng',
'Jeneponto',
'Takalar',
'Gowa',
'Sinjai',
'Bone',
'Maros',
'Pangkajene Kepulauan',
'Barru',
'Soppeng',
'Wajo',
'Sidenreng Rappang',
'Pinrang',
'Enrekang',
'Luwu',
'Tana Toraja',
'Luwu Utara',
'Luwu Timur',
'Toraja Utara',
'Kota Makassar',
'Kota Pare Pare',
'Kota Palopo',
'Kolaka',
'Konawe',
'Muna',
'Buton',
'Konawe Selatan',
'Bombana',
'Wakatobi',
'Kolaka Utara',
'Konawe Utara',
'Buton Utara',
'Kolaka Timur',
'Konawe Kepulauan',
'Muna Barat',
'Buton Tengah',
'Buton Selatan',
'Kota Kendari',
'Kota Bau Bau',
'Gorontalo',
'Boalemo',
'Bone Bolango',
'Pahuwato',
'Gorontalo Utara',
'Kota Gorontalo',
'Pasangkayu (Mamuju Utara)',
'Mamuju',
'Mamasa',
'Polewali Mandar',
'Majene',
'Mamuju Tengah',
'Maluku Tengah',
'Maluku Tenggara',
'Kepulauan Tanimbar (Maluku Tenggara Barat)',
'Buru',
'Seram Bagian Timur',
'Seram Bagian Barat',
'Kepulauan Aru',
'Maluku Barat Daya',
'Buru Selatan',
'Kota Ambon',
'Kota Tual',
'Halmahera Barat',
'Halmahera Tengah',
'Halmahera Utara',
'Halmahera Selatan',
'Kepulauan Sula',
'Halmahera Timur',
'Pulau Morotai',
'Pulau Taliabu',
'Kota Ternate',
'Kota Tidore Kepulauan',
'Jayapura',
'Kepulauan Yapen',
'Biak Numfor',
'Sarmi',
'Keerom',
'Waropen',
'Supiori',
'Mamberamo Raya',
'Kota Jayapura',
'Manokwari',
'Fak Fak',
'Teluk Bintuni',
'Teluk Wondama',
'Kaimana',
'Manokwari Selatan',
'Pegunungan Arfak',
'Merauke',
'Boven Digoel',
'Mappi',
'Asmat',
'Nabire',
'Puncak Jaya',
'Paniai',
'Mimika',
'Puncak',
'Dogiyai',
'Intan Jaya',
'Deiyai',
'Jayawijaya',
'Pegunungan Bintang',
'Yahukimo',
'Tolikara',
'Mamberamo Tengah',
'Yalimo',
'Lanny Jaya',
'Nduga',
'Sorong',
'Sorong Selatan',
'Raja Ampat',
'Tambrauw',
'Maybrat',
'Kota Sorong',
];
export default INDONESIAN_CITIES;
@@ -1,76 +0,0 @@
import { useTeamById, useTeamMembers, useUserMe, useTeamSubmission } from '@imphnen-frontend-service/service';
/**
* Hook to check if current user is a member of the team
*/
export const useTeamMembership = (teamId: string) => {
const { data: userData } = useUserMe();
const { data: membersData } = useTeamMembers(teamId);
const currentUser = userData?.data;
const members = membersData?.data || [];
const isMember = members.some(m => m.user_id === currentUser?.id);
const currentMember = members.find(m => m.user_id === currentUser?.id);
return {
isMember,
currentMember,
memberRole: currentMember?.role,
memberStatus: currentMember?.status,
};
};
/**
* Hook to check if current user is the team leader
*/
export const useIsTeamLeader = (teamId: string) => {
const { data: teamData } = useTeamById(teamId);
const { data: userData } = useUserMe();
const team = teamData?.data;
const currentUser = userData?.data;
const isLeader = currentUser?.id === team?.leader_id;
return {
isLeader,
leaderId: team?.leader_id,
};
};
/**
* Hook to get team submission status
*/
export const useTeamSubmissionStatus = (teamId: string) => {
const { data: submissionData, isLoading } = useTeamSubmission(teamId, !!teamId);
const hasSubmission = !!submissionData?.data;
const submission = submissionData?.data;
const isSubmitted = submission?.status === 'submitted';
return {
hasSubmission,
isSubmitted,
submission,
isLoading,
};
};
/**
* Hook to check if current user can perform team actions
*/
export const useTeamPermissions = (teamId: string) => {
const { isLeader } = useIsTeamLeader(teamId);
const { isMember } = useTeamMembership(teamId);
const { hasSubmission } = useTeamSubmissionStatus(teamId);
return {
canEditTeam: isLeader,
canManageMembers: isLeader,
canSubmitProject: isLeader && !hasSubmission,
canViewTeam: isMember,
canViewSubmission: isMember,
canAccessChat: isMember,
};
};
-136
View File
@@ -1,136 +0,0 @@
@import url('https://fonts.googleapis.com/css2?family=Bai+Jamjuree:ital,wght@0,200;0,300;0,400;0,500;0,600;0,700;1,200;1,300;1,400;1,500;1,600;1,700&display=swap');
@import 'tailwindcss';
@source "../../../libs/ui/**/*.{ts,tsx}";
@theme {
--color-primary-50: #f0f8ff;
--color-primary-100: #e1f0fd;
--color-primary-200: #bce1fb;
--color-primary-300: #81cbf8;
--color-primary-400: #3eb0f2;
--color-primary-500: #23a1eb;
--color-primary-600: #0877c1;
--color-primary-700: #085f9c;
--color-primary-800: #0b5181;
--color-primary-900: #0f446b;
--color-primary-950: #0a2b47;
--color-neutral-50: #f6f6f6;
--color-neutral-100: #e7e7e7;
--color-neutral-200: #d1d1d1;
--color-neutral-300: #b0b0b0;
--color-neutral-400: #888888;
--color-neutral-500: #6d6d6d;
--color-neutral-600: #5d5d5d;
--color-neutral-700: #4f4f4f;
--color-neutral-800: #454545;
--color-neutral-900: #3d3d3d;
--color-neutral-950: #2b2b2b;
--color-success-100: #e0fbd8;
--color-success-200: #bcf8b0;
--color-success-300: #8eea85;
--color-success-400: #63d564;
--color-success-500: #35ba43;
--color-success-600: #269f3e;
--color-success-700: #1a8439;
--color-success-800: #106b32;
--color-success-900: #0b592f;
--color-info-100: #ccfcfe;
--color-info-200: #9bf3fd;
--color-info-300: #67e3fb;
--color-info-400: #42cdf8;
--color-info-500: #04acf3;
--color-info-600: #0185d0;
--color-info-700: #0264af;
--color-info-800: #01478d;
--color-info-900: #003375;
--color-warning-100: #fffcd3;
--color-warning-200: #fffaa9;
--color-warning-300: #fff67d;
--color-warning-400: #fff25d;
--color-warning-500: #ffed27;
--color-warning-600: #dbc91d;
--color-warning-700: #b7a714;
--color-warning-800: #93850b;
--color-warning-900: #7a6d07;
--color-danger-100: #ffe8da;
--color-danger-200: #ffcbb3;
--color-danger-300: #ffaa8d;
--color-danger-400: #ff8870;
--color-danger-500: #ff5242;
--color-danger-600: #da3030;
--color-danger-700: #b7212d;
--color-danger-800: #93152a;
--color-danger-900: #7a0c27;
--radius-none: 0px;
--radius-sm: 2px;
--radius-md: 4px;
--radius-lg: 8px;
--radius-xl: 12px;
--radius-2xl: 16px;
--radius-3xl: 24px;
--radius-full: 50%;
--font-bai-jamjuree: 'Bai Jamjuree', sans-serif;
--text-h1: 3.833rem;
/* ~46px */
--text-h2: 3.083rem;
/* ~37px */
--text-h3: 2.417rem;
/* ~29px */
--text-p1: 1.917rem;
/* ~23px */
--text-p2: 1.583rem;
/* ~19px */
--text-p3: 1.25rem;
/* 15px */
--text-label1: 1rem;
/* 12px */
--text-label2: 0.833rem;
/* ~10px */
--text-label3: 0.677rem;
/* ~8px */
}
@layer base {
*,
::after,
::before,
::backdrop,
::file-selector-button {
border-color: var(--color-neutral-200, currentColor);
}
/* Custom scrollbar */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: var(--color-neutral-50);
}
::-webkit-scrollbar-thumb {
background: #cccccc;
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--color-neutral-300);
}
html {
font-family: 'Bai Jamjuree', sans-serif;
font-weight: 400;
font-size: 12px;
line-height: 1.2;
}
}
-46
View File
@@ -1,46 +0,0 @@
import { createRoot } from 'react-dom/client';
import { StrictMode } from 'react';
import { createBrowserRouter, RouteObject, RouterProvider } from 'react-router';
import {
add404PageToRoutesChildren,
addErrorElementToRoutes,
convertPagesToRoute,
ModalLoginProvider,
QueryProvider,
} from '@imphnen-frontend-service/utils';
import { Toaster } from 'sonner';
import './index.css';
const files = import.meta.glob('./app/**/*(page|layout).tsx');
const errorFiles = import.meta.glob('./app/**/*error.tsx');
const notFoundFiles = import.meta.glob('./app/**/*404.tsx');
const loadingFiles = import.meta.glob('./app/**/*loading.tsx');
const routes = convertPagesToRoute(files, loadingFiles) as RouteObject;
addErrorElementToRoutes(errorFiles, routes);
add404PageToRoutesChildren(notFoundFiles, routes);
const router = createBrowserRouter([
{
...routes,
// MIDDLEWARE TEMPORARILY DISABLED - causing infinite loops with React Router v7
// TODO: Implement auth checks at component level or use different pattern
// loader: middleware,
// shouldRevalidate: () => false,
},
]);
const rootElement = document.getElementById('root');
if (!rootElement) throw new Error('Failed to find the root element');
createRoot(rootElement).render(
<StrictMode>
<QueryProvider>
<ModalLoginProvider>
<Toaster position="top-right" />
<RouterProvider router={router} />
</ModalLoginProvider>
</QueryProvider>
</StrictMode>
);
-141
View File
@@ -1,141 +0,0 @@
import { SessionUser } from '@imphnen-frontend-service/utils';
import { supabase } from '@imphnen-frontend-service/service';
import { LoaderFunctionArgs, redirect } from 'react-router';
const mappingPublicRoutes = [
'/',
];
const mappingOnboardingRoutes = [
'/onboarding/user',
];
const mappingRoutePermissions = [
{
path: '/dashboard',
permissions: [],
},
{
path: '/teams/browse',
permissions: [],
},
{
path: '/teams/create',
permissions: [],
},
];
const mappingPublicPrefixRoutes = [
'/hackathons',
];
// Cache to prevent redundant checks (cache for 5 seconds)
const onboardingCache = new Map<string, { hasLocation: boolean; timestamp: number }>();
const CACHE_DURATION = 5000; // 5 seconds
export const middleware = async ({ request }: LoaderFunctionArgs) => {
const url = new URL(request.url);
const pathname = url.pathname;
console.log('[Middleware] Checking route:', pathname);
// Get session from Supabase (authoritative source)
const { data: { session: supabaseSession }, error: sessionError } = await supabase.auth.getSession();
// Handle session errors
if (sessionError) {
console.error('[Middleware] Session error:', sessionError);
// Don't redirect on session errors, let the app handle it
}
// Allow to access the hackathon pages without authentication
if (mappingPublicPrefixRoutes.some((prefix) => pathname.startsWith(prefix))) {
return null;
}
// Public routes - allow everyone to view the landing page
if (mappingPublicRoutes.includes(pathname)) {
return null;
}
// Auth callback - allow without authentication check (for OAuth callback)
if (pathname === '/auth/callback') {
return null;
}
// Auth routes (all /auth/* paths) - redirect to dashboard if already authenticated
if (pathname.startsWith('/auth')) {
if (supabaseSession) return redirect('/dashboard');
return null;
}
// Require authentication for all other routes - ONLY check Supabase session
if (!supabaseSession) {
return redirect('/auth/login');
}
// Check if user has completed onboarding by querying database (not localStorage!)
// Skip onboarding check for onboarding routes themselves
if (!mappingOnboardingRoutes.includes(pathname)) {
try {
const userId = supabaseSession.user.id;
const now = Date.now();
// Check cache first
const cached = onboardingCache.get(userId);
let hasLocation = false;
if (cached && (now - cached.timestamp) < CACHE_DURATION) {
console.log('[Middleware] Using cached onboarding status');
hasLocation = cached.hasLocation;
} else {
console.log('[Middleware] Fetching fresh onboarding status');
const { data: userData, error: userError } = await supabase
.from('users')
.select('location')
.eq('id', userId)
.single();
if (userError) {
console.error('[Middleware] Failed to fetch user data:', userError);
// If we can't fetch user data, allow access (don't break the app)
return null;
}
hasLocation = !!userData?.location;
// Update cache
onboardingCache.set(userId, { hasLocation, timestamp: now });
}
if (!hasLocation) {
return redirect('/onboarding/user');
}
} catch (error) {
console.error('[Middleware] Unexpected error checking onboarding:', error);
// On error, allow access (fail open)
return null;
}
}
// Check route permissions using fresh user data from Zustand (for UI metadata)
const session = SessionUser.get();
const userPermissions =
session?.role?.permissions?.map?.((perm) => perm?.name) ?? [];
const matchedRoute = mappingRoutePermissions.find(
(route) => route.path === pathname
);
if (matchedRoute) {
const hasPermission =
!matchedRoute.permissions ||
matchedRoute.permissions.some((perm) => userPermissions.includes(perm));
if (!hasPermission) {
return redirect('/dashboard');
}
}
return null;
};
-27
View File
@@ -1,27 +0,0 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../dist/out-tsc",
"types": [
"node",
"@nx/react/typings/cssmodule.d.ts",
"@nx/react/typings/image.d.ts",
"vite/client"
]
},
"exclude": [
"src/**/*.spec.ts",
"src/**/*.test.ts",
"src/**/*.spec.tsx",
"src/**/*.test.tsx",
"src/**/*.spec.js",
"src/**/*.test.js",
"src/**/*.spec.jsx",
"src/**/*.test.jsx",
"vite.config.ts",
"vite.config.mts",
"vitest.config.ts",
"vitest.config.mts"
],
"include": ["src/**/*.js", "src/**/*.jsx", "src/**/*.ts", "src/**/*.tsx"]
}

Some files were not shown because too many files have changed in this diff Show More