273 lines
9.3 KiB
TypeScript
273 lines
9.3 KiB
TypeScript
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;
|