625 lines
16 KiB
TypeScript
625 lines
16 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useCallback, useEffect } from "react";
|
|
import { useRouter } from "@/components/navigation";
|
|
import { toast } from "sonner";
|
|
import {
|
|
RegistrationFormData,
|
|
JournalistRegistrationData,
|
|
PersonnelRegistrationData,
|
|
GeneralRegistrationData,
|
|
InstituteData,
|
|
UserCategory,
|
|
PasswordValidation,
|
|
TimerState,
|
|
Association,
|
|
OTPRequestResponse,
|
|
OTPVerificationResponse,
|
|
LocationResponse,
|
|
InstituteResponse,
|
|
RegistrationResponse,
|
|
LocationData,
|
|
} from "@/types/registration";
|
|
import {
|
|
requestOTP,
|
|
verifyRegistrationOTP,
|
|
listProvince,
|
|
listCity,
|
|
listDistricts,
|
|
listInstitusi,
|
|
saveInstitutes,
|
|
postRegistration,
|
|
getDataJournalist,
|
|
getDataPersonil,
|
|
verifyOTP,
|
|
} from "@/service/auth";
|
|
import {
|
|
validatePassword,
|
|
validateUsername,
|
|
validateEmail,
|
|
validatePhoneNumber,
|
|
createTimer,
|
|
formatTime,
|
|
sanitizeRegistrationData,
|
|
sanitizeInstituteData,
|
|
isValidCategory,
|
|
getCategoryRoleId,
|
|
showRegistrationError,
|
|
showRegistrationSuccess,
|
|
showRegistrationInfo,
|
|
transformRegistrationData,
|
|
createInitialFormData,
|
|
validateIdentityData,
|
|
RegistrationRateLimiter,
|
|
REGISTRATION_CONSTANTS,
|
|
} from "@/lib/registration-utils";
|
|
|
|
// Global rate limiter instance
|
|
const registrationRateLimiter = new RegistrationRateLimiter();
|
|
|
|
// Hook for OTP operations
|
|
export const useOTP = () => {
|
|
const [loading, setLoading] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [timer, setTimer] = useState<TimerState>(createTimer());
|
|
|
|
const startTimer = useCallback(() => {
|
|
setTimer(createTimer());
|
|
}, []);
|
|
|
|
const stopTimer = useCallback(() => {
|
|
setTimer((prev) => ({
|
|
...prev,
|
|
isActive: false,
|
|
isExpired: true,
|
|
}));
|
|
}, []);
|
|
|
|
// Timer effect
|
|
useEffect(() => {
|
|
if (!timer.isActive || timer.countdown <= 0) {
|
|
setTimer((prev) => ({
|
|
...prev,
|
|
isActive: false,
|
|
isExpired: true,
|
|
}));
|
|
return;
|
|
}
|
|
|
|
const interval = setInterval(() => {
|
|
setTimer((prev) => ({
|
|
...prev,
|
|
countdown: Math.max(0, prev.countdown - 1000),
|
|
}));
|
|
}, 1000);
|
|
|
|
return () => clearInterval(interval);
|
|
}, [timer.isActive, timer.countdown]);
|
|
|
|
const requestOTPCode = useCallback(
|
|
async (
|
|
email: string,
|
|
category: UserCategory,
|
|
memberIdentity?: string
|
|
): Promise<boolean> => {
|
|
try {
|
|
setLoading(true);
|
|
setError(null);
|
|
|
|
// Check rate limiting
|
|
const identifier = `${email}-${category}`;
|
|
if (!registrationRateLimiter.canAttempt(identifier)) {
|
|
const remainingAttempts =
|
|
registrationRateLimiter.getRemainingAttempts(identifier);
|
|
throw new Error(
|
|
`Too many OTP requests. Please try again later. Remaining attempts: ${remainingAttempts}`
|
|
);
|
|
}
|
|
|
|
const data = {
|
|
memberIdentity: memberIdentity || null,
|
|
email,
|
|
category: getCategoryRoleId(category),
|
|
name: email.split("@")[0],
|
|
};
|
|
|
|
// Debug logging
|
|
console.log("OTP Request Data:", data);
|
|
console.log("Category before conversion:", category);
|
|
console.log("Category after conversion:", getCategoryRoleId(category));
|
|
|
|
const response = await requestOTP(data);
|
|
|
|
if (response?.error) {
|
|
registrationRateLimiter.recordAttempt(identifier);
|
|
throw new Error(response.message || "Failed to send OTP");
|
|
}
|
|
|
|
// Start timer on successful OTP request
|
|
startTimer();
|
|
showRegistrationInfo("OTP sent successfully. Please check your email.");
|
|
|
|
return true;
|
|
} catch (error: any) {
|
|
const errorMessage = error?.message || "Failed to send OTP";
|
|
setError(errorMessage);
|
|
showRegistrationError(error, "Failed to send OTP");
|
|
return false;
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
},
|
|
[startTimer]
|
|
);
|
|
|
|
const verifyOTPCode = useCallback(
|
|
async (
|
|
email: string,
|
|
otp: string,
|
|
category: UserCategory,
|
|
memberIdentity?: string
|
|
): Promise<any> => {
|
|
try {
|
|
setLoading(true);
|
|
setError(null);
|
|
|
|
if (otp.length !== 6) {
|
|
throw new Error("OTP must be exactly 6 digits");
|
|
}
|
|
|
|
const data = {
|
|
memberIdentity: memberIdentity || null,
|
|
email,
|
|
otp,
|
|
category: getCategoryRoleId(category),
|
|
};
|
|
|
|
const response = await verifyOTP(data.email, data.otp);
|
|
|
|
if (response?.error) {
|
|
throw new Error(response.message || "OTP verification failed");
|
|
}
|
|
|
|
stopTimer();
|
|
showRegistrationSuccess("OTP verified successfully");
|
|
|
|
return response?.data?.userData;
|
|
} catch (error: any) {
|
|
const errorMessage = error?.message || "OTP verification failed";
|
|
setError(errorMessage);
|
|
showRegistrationError(error, "OTP verification failed");
|
|
throw error;
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
},
|
|
[stopTimer]
|
|
);
|
|
|
|
const resendOTP = useCallback(
|
|
async (
|
|
email: string,
|
|
category: UserCategory,
|
|
memberIdentity?: string
|
|
): Promise<boolean> => {
|
|
return await requestOTPCode(email, category, memberIdentity);
|
|
},
|
|
[requestOTPCode]
|
|
);
|
|
|
|
return {
|
|
requestOTP: requestOTPCode,
|
|
verifyOTP: verifyOTPCode,
|
|
resendOTP,
|
|
loading,
|
|
error,
|
|
timer,
|
|
formattedTime: formatTime(timer.countdown),
|
|
canResend: timer.isExpired,
|
|
};
|
|
};
|
|
|
|
// Hook for location data
|
|
export const useLocationData = () => {
|
|
const [provinces, setProvinces] = useState<LocationData[]>([]);
|
|
const [cities, setCities] = useState<LocationData[]>([]);
|
|
const [districts, setDistricts] = useState<LocationData[]>([]);
|
|
const [loading, setLoading] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
const fetchProvinces = useCallback(async () => {
|
|
try {
|
|
setLoading(true);
|
|
setError(null);
|
|
|
|
const response = await listProvince();
|
|
|
|
if (!response || response.error) {
|
|
throw new Error(response?.message || "Failed to fetch provinces");
|
|
}
|
|
|
|
setProvinces(response?.data?.data || []);
|
|
} catch (error: any) {
|
|
const errorMessage = error?.message || "Failed to fetch provinces";
|
|
setError(errorMessage);
|
|
showRegistrationError(error, "Failed to fetch provinces");
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, []);
|
|
|
|
const fetchCities = useCallback(async (provinceId: string) => {
|
|
try {
|
|
setLoading(true);
|
|
setError(null);
|
|
|
|
const response = await listCity(provinceId);
|
|
|
|
if (response?.error) {
|
|
throw new Error(response.message || "Failed to fetch cities");
|
|
}
|
|
|
|
setCities(response?.data?.data || []);
|
|
setDistricts([]); // Reset districts when province changes
|
|
} catch (error: any) {
|
|
const errorMessage = error?.message || "Failed to fetch cities";
|
|
setError(errorMessage);
|
|
showRegistrationError(error, "Failed to fetch cities");
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, []);
|
|
|
|
const fetchDistricts = useCallback(async (cityId: string) => {
|
|
try {
|
|
setLoading(true);
|
|
setError(null);
|
|
|
|
const response = await listDistricts(cityId);
|
|
|
|
if (response?.error) {
|
|
throw new Error(response.message || "Failed to fetch districts");
|
|
}
|
|
|
|
setDistricts(response?.data?.data || []);
|
|
} catch (error: any) {
|
|
const errorMessage = error?.message || "Failed to fetch districts";
|
|
setError(errorMessage);
|
|
showRegistrationError(error, "Failed to fetch districts");
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, []);
|
|
|
|
// Load provinces on mount
|
|
useEffect(() => {
|
|
fetchProvinces();
|
|
}, [fetchProvinces]);
|
|
|
|
return {
|
|
provinces,
|
|
cities,
|
|
districts,
|
|
loading,
|
|
error,
|
|
fetchProvinces,
|
|
fetchCities,
|
|
fetchDistricts,
|
|
};
|
|
};
|
|
|
|
// Hook for institute data
|
|
export const useInstituteData = (category?: number) => {
|
|
const [institutes, setInstitutes] = useState<InstituteData[]>([]);
|
|
const [loading, setLoading] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
const fetchInstitutes = useCallback(
|
|
async (categoryId?: number) => {
|
|
try {
|
|
setLoading(true);
|
|
setError(null);
|
|
|
|
const response = await listInstitusi(categoryId || category);
|
|
|
|
if (response?.error) {
|
|
throw new Error(response.message || "Failed to fetch institutes");
|
|
}
|
|
|
|
setInstitutes(response?.data?.data || []);
|
|
} catch (error: any) {
|
|
const errorMessage = error?.message || "Failed to fetch institutes";
|
|
setError(errorMessage);
|
|
showRegistrationError(error, "Failed to fetch institutes");
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
},
|
|
[category]
|
|
);
|
|
|
|
const saveInstitute = useCallback(
|
|
async (instituteData: InstituteData): Promise<number> => {
|
|
try {
|
|
setLoading(true);
|
|
setError(null);
|
|
|
|
const sanitizedData = sanitizeInstituteData(instituteData);
|
|
|
|
const response = await saveInstitutes({
|
|
name: sanitizedData.name,
|
|
address: sanitizedData.address,
|
|
categoryRoleId: category || 6, // Use provided category or default to Journalist category
|
|
});
|
|
|
|
if (response?.error) {
|
|
throw new Error(response.message || "Failed to save institute");
|
|
}
|
|
|
|
return response?.data?.data?.id || 1;
|
|
} catch (error: any) {
|
|
const errorMessage = error?.message || "Failed to save institute";
|
|
setError(errorMessage);
|
|
showRegistrationError(error, "Failed to save institute");
|
|
throw error;
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
},
|
|
[category]
|
|
);
|
|
|
|
// Load institutes on mount if category is provided
|
|
useEffect(() => {
|
|
if (category) {
|
|
fetchInstitutes();
|
|
}
|
|
}, [fetchInstitutes, category]);
|
|
|
|
return {
|
|
institutes,
|
|
loading,
|
|
error,
|
|
fetchInstitutes,
|
|
saveInstitute,
|
|
};
|
|
};
|
|
|
|
// Hook for user data validation
|
|
export const useUserDataValidation = () => {
|
|
const [loading, setLoading] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
const validateJournalistData = useCallback(
|
|
async (certificateNumber: string): Promise<any> => {
|
|
try {
|
|
setLoading(true);
|
|
setError(null);
|
|
|
|
const response = await getDataJournalist(certificateNumber);
|
|
|
|
if (response?.error) {
|
|
throw new Error(
|
|
response.message || "Invalid journalist certificate number"
|
|
);
|
|
}
|
|
|
|
return response?.data?.data;
|
|
} catch (error: any) {
|
|
const errorMessage =
|
|
error?.message || "Failed to validate journalist data";
|
|
setError(errorMessage);
|
|
showRegistrationError(error, "Failed to validate journalist data");
|
|
throw error;
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
},
|
|
[]
|
|
);
|
|
|
|
const validatePersonnelData = useCallback(
|
|
async (policeNumber: string): Promise<any> => {
|
|
try {
|
|
setLoading(true);
|
|
setError(null);
|
|
|
|
const response = await getDataPersonil(policeNumber);
|
|
|
|
if (response?.error) {
|
|
throw new Error(response.message || "Invalid police number");
|
|
}
|
|
|
|
return response?.data?.data;
|
|
} catch (error: any) {
|
|
const errorMessage =
|
|
error?.message || "Failed to validate personnel data";
|
|
setError(errorMessage);
|
|
showRegistrationError(error, "Failed to validate personnel data");
|
|
throw error;
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
},
|
|
[]
|
|
);
|
|
|
|
return {
|
|
validateJournalistData,
|
|
validatePersonnelData,
|
|
loading,
|
|
error,
|
|
};
|
|
};
|
|
|
|
// Hook for registration submission
|
|
export const useRegistration = () => {
|
|
const router = useRouter();
|
|
const [loading, setLoading] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
const submitRegistration = useCallback(
|
|
async (
|
|
data: RegistrationFormData,
|
|
category: UserCategory,
|
|
userData: any,
|
|
instituteId?: number
|
|
): Promise<boolean> => {
|
|
try {
|
|
setLoading(true);
|
|
setError(null);
|
|
|
|
// Sanitize and validate data
|
|
const sanitizedData = sanitizeRegistrationData(data);
|
|
|
|
// Validate password
|
|
const passwordValidation = validatePassword(
|
|
sanitizedData.password,
|
|
sanitizedData.passwordConf
|
|
);
|
|
if (!passwordValidation.isValid) {
|
|
throw new Error(passwordValidation.errors[0]);
|
|
}
|
|
|
|
// Validate username
|
|
const usernameValidation = validateUsername(sanitizedData.username);
|
|
if (!usernameValidation.isValid) {
|
|
throw new Error(usernameValidation.error!);
|
|
}
|
|
|
|
// Transform data for API
|
|
const transformedData = transformRegistrationData(
|
|
sanitizedData,
|
|
category,
|
|
userData,
|
|
instituteId
|
|
);
|
|
|
|
const response = await postRegistration(transformedData);
|
|
console.log("PPPP", transformedData);
|
|
if (response?.error) {
|
|
throw new Error(response.message || "Registration failed");
|
|
}
|
|
|
|
showRegistrationSuccess(
|
|
"Registration successful! Please check your email for verification."
|
|
);
|
|
|
|
// Redirect to login page
|
|
setTimeout(() => {
|
|
router.push("/auth");
|
|
}, 2000);
|
|
|
|
return true;
|
|
} catch (error: any) {
|
|
const errorMessage = error?.message || "Registration failed";
|
|
setError(errorMessage);
|
|
showRegistrationError(error, "Registration failed");
|
|
return false;
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
},
|
|
[router]
|
|
);
|
|
|
|
return {
|
|
submitRegistration,
|
|
loading,
|
|
error,
|
|
};
|
|
};
|
|
|
|
// Hook for form validation
|
|
export const useFormValidation = () => {
|
|
const validateIdentityForm = useCallback(
|
|
(
|
|
data:
|
|
| JournalistRegistrationData
|
|
| PersonnelRegistrationData
|
|
| GeneralRegistrationData,
|
|
category: UserCategory
|
|
): { isValid: boolean; errors: string[] } => {
|
|
return validateIdentityData(data, category);
|
|
},
|
|
[]
|
|
);
|
|
|
|
const validateProfileForm = useCallback(
|
|
(data: RegistrationFormData): { isValid: boolean; errors: string[] } => {
|
|
const errors: string[] = [];
|
|
|
|
// Validate required fields
|
|
if (!data.firstName?.trim()) {
|
|
errors.push("Full name is required");
|
|
}
|
|
|
|
if (!data.username?.trim()) {
|
|
errors.push("Username is required");
|
|
} else {
|
|
const usernameValidation = validateUsername(data.username);
|
|
if (!usernameValidation.isValid) {
|
|
errors.push(usernameValidation.error!);
|
|
}
|
|
}
|
|
|
|
if (!data.email?.trim()) {
|
|
errors.push("Email is required");
|
|
} else {
|
|
const emailValidation = validateEmail(data.email);
|
|
if (!emailValidation.isValid) {
|
|
errors.push(emailValidation.error!);
|
|
}
|
|
}
|
|
|
|
if (!data.phoneNumber?.trim()) {
|
|
errors.push("Phone number is required");
|
|
} else {
|
|
const phoneValidation = validatePhoneNumber(data.phoneNumber);
|
|
if (!phoneValidation.isValid) {
|
|
errors.push(phoneValidation.error!);
|
|
}
|
|
}
|
|
|
|
if (!data.address?.trim()) {
|
|
errors.push("Address is required");
|
|
}
|
|
|
|
if (!data.provinsi) {
|
|
errors.push("Province is required");
|
|
}
|
|
|
|
if (!data.kota) {
|
|
errors.push("City is required");
|
|
}
|
|
|
|
if (!data.kecamatan) {
|
|
errors.push("Subdistrict is required");
|
|
}
|
|
|
|
if (!data.password) {
|
|
errors.push("Password is required");
|
|
} else {
|
|
const passwordValidation = validatePassword(
|
|
data.password,
|
|
data.passwordConf
|
|
);
|
|
if (!passwordValidation.isValid) {
|
|
errors.push(passwordValidation.errors[0]);
|
|
}
|
|
}
|
|
|
|
return {
|
|
isValid: errors.length === 0,
|
|
errors,
|
|
};
|
|
},
|
|
[]
|
|
);
|
|
|
|
return {
|
|
validateIdentityForm,
|
|
validateProfileForm,
|
|
};
|
|
};
|