첫 커밋
This commit is contained in:
5
src/hooks/index.ts
Normal file
5
src/hooks/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export { useAuth } from './useAuth';
|
||||
export { useLoginForm } from './useLoginForm';
|
||||
export { useAppBridge } from './useAppBridge';
|
||||
export { useScrollDirection } from './useScrollDirection';
|
||||
export { useSearchFilter } from './useSearchFilter';
|
||||
270
src/hooks/useAppBridge.tsx
Normal file
270
src/hooks/useAppBridge.tsx
Normal file
@@ -0,0 +1,270 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { appBridge } from '@/utils/appBridge';
|
||||
import { DeviceInfo, LocationInfo, ContactInfo, ShareContent } from '@/types';
|
||||
|
||||
interface UseAppBridgeReturn {
|
||||
isNativeEnvironment: boolean;
|
||||
isAndroid: boolean;
|
||||
isIOS: boolean;
|
||||
deviceInfo: DeviceInfo | null;
|
||||
|
||||
// 네비게이션
|
||||
navigateBack: () => Promise<void>;
|
||||
navigateTo: (path: string) => Promise<void>;
|
||||
navigateToLogin: () => Promise<void>;
|
||||
closeWebView: () => Promise<void>;
|
||||
|
||||
// 알림
|
||||
showToast: (message: string, duration?: number) => Promise<void>;
|
||||
showAlert: (title: string, message: string) => Promise<void>;
|
||||
showConfirm: (title: string, message: string) => Promise<boolean>;
|
||||
|
||||
// 저장소
|
||||
setStorage: (key: string, value: unknown) => Promise<void>;
|
||||
// getStorage: <T = unknown>(key: string) => Promise<T | null>;
|
||||
removeStorage: (key: string) => Promise<void>;
|
||||
|
||||
/*
|
||||
// 미디어
|
||||
openCamera: (options?: { quality?: number; allowEdit?: boolean }) => Promise<string>;
|
||||
openGallery: (options?: { multiple?: boolean; maxCount?: number }) => Promise<string[]>;
|
||||
|
||||
// 위치
|
||||
getLocation: (options?: { enableHighAccuracy?: boolean; timeout?: number }) => Promise<LocationInfo>;
|
||||
|
||||
// 연락처
|
||||
getContacts: () => Promise<ContactInfo[]>;
|
||||
*/
|
||||
// 공유
|
||||
shareContent: (content: ShareContent) => Promise<void>;
|
||||
}
|
||||
|
||||
export const useAppBridge = (): UseAppBridgeReturn => {
|
||||
const [deviceInfo, setDeviceInfo] = useState<DeviceInfo | null>(null);
|
||||
|
||||
const isNativeEnvironment = appBridge.isNativeEnvironment();
|
||||
const isAndroid = appBridge.isAndroid();
|
||||
const isIOS = appBridge.isIOS();
|
||||
|
||||
useEffect(() => {
|
||||
if (isNativeEnvironment) {
|
||||
appBridge.safeCall(
|
||||
() => appBridge.getDeviceInfo(),
|
||||
undefined,
|
||||
(error) => console.warn('Failed to get device info:', error)
|
||||
).then(info => {
|
||||
if (info) setDeviceInfo(info);
|
||||
});
|
||||
}
|
||||
}, [isNativeEnvironment]);
|
||||
|
||||
const navigateBack = useCallback(async (): Promise<void> => {
|
||||
if (!isNativeEnvironment) {
|
||||
window.history.back();
|
||||
return;
|
||||
}
|
||||
return appBridge.safeCall(() => appBridge.navigateBack());
|
||||
}, [isNativeEnvironment]);
|
||||
|
||||
const navigateTo = useCallback(async (path: string): Promise<void> => {
|
||||
if (!isNativeEnvironment) {
|
||||
window.location.href = path;
|
||||
return;
|
||||
}
|
||||
return appBridge.safeCall(() => appBridge.navigateTo(path));
|
||||
}, [isNativeEnvironment]);
|
||||
|
||||
const closeWebView = useCallback(async (): Promise<void> => {
|
||||
if (!isNativeEnvironment) {
|
||||
window.close();
|
||||
return;
|
||||
}
|
||||
return appBridge.safeCall(() => appBridge.closeWebView());
|
||||
}, [isNativeEnvironment]);
|
||||
|
||||
const navigateToLogin = useCallback(async (): Promise<void> => {
|
||||
if (!isNativeEnvironment) {
|
||||
// 웹 환경에서는 로그인 페이지로 이동
|
||||
window.location.href = '/login';
|
||||
return;
|
||||
}
|
||||
// 네이티브 환경에서는 로그인 화면으로 이동
|
||||
return appBridge.safeCall(() => appBridge.navigateTo('/login'));
|
||||
}, [isNativeEnvironment]);
|
||||
|
||||
const showToast = useCallback(async (message: string, duration = 3000): Promise<void> => {
|
||||
if (!isNativeEnvironment) {
|
||||
// 웹 환경에서는 간단한 토스트 메시지 구현
|
||||
const toast = document.createElement('div');
|
||||
toast.className = 'fixed top-4 right-4 bg-gray-800 text-white px-4 py-2 rounded-md z-50 animate-fade-in';
|
||||
toast.textContent = message;
|
||||
document.body.appendChild(toast);
|
||||
|
||||
setTimeout(() => {
|
||||
document.body.removeChild(toast);
|
||||
}, duration);
|
||||
return;
|
||||
}
|
||||
return appBridge.safeCall(() => appBridge.showToast(message, duration));
|
||||
}, [isNativeEnvironment]);
|
||||
|
||||
const showAlert = useCallback(async (title: string, message: string): Promise<void> => {
|
||||
if (!isNativeEnvironment) {
|
||||
alert(`${title}\n\n${message}`);
|
||||
return;
|
||||
}
|
||||
return appBridge.safeCall(() => appBridge.showAlert(title, message));
|
||||
}, [isNativeEnvironment]);
|
||||
|
||||
const showConfirm = useCallback(async (title: string, message: string): Promise<boolean> => {
|
||||
if (!isNativeEnvironment) {
|
||||
return confirm(`${title}\n\n${message}`);
|
||||
}
|
||||
const result = await appBridge.safeCall(() => appBridge.showConfirm(title, message), false);
|
||||
return result || false;
|
||||
}, [isNativeEnvironment]);
|
||||
|
||||
const setStorage = useCallback(async (key: string, value: unknown): Promise<void> => {
|
||||
if (!isNativeEnvironment) {
|
||||
localStorage.setItem(key, JSON.stringify(value));
|
||||
return;
|
||||
}
|
||||
return appBridge.safeCall(() => appBridge.setStorage(key, value));
|
||||
}, [isNativeEnvironment]);
|
||||
|
||||
/*
|
||||
const getStorage = useCallback(async <T = unknown>(key: string): Promise<T | null> => {
|
||||
if (!isNativeEnvironment) {
|
||||
const item = localStorage.getItem(key);
|
||||
return item ? JSON.parse(item) : null;
|
||||
}
|
||||
return appBridge.safeCall(() => appBridge.getStorage(key), null);
|
||||
}, [isNativeEnvironment]);
|
||||
*/
|
||||
const removeStorage = useCallback(async (key: string): Promise<void> => {
|
||||
if (!isNativeEnvironment) {
|
||||
localStorage.removeItem(key);
|
||||
return;
|
||||
}
|
||||
return appBridge.safeCall(() => appBridge.removeStorage(key));
|
||||
}, [isNativeEnvironment]);
|
||||
|
||||
/*
|
||||
const openCamera = useCallback(async (
|
||||
options?: { quality?: number; allowEdit?: boolean }
|
||||
): Promise<string> => {
|
||||
if (!isNativeEnvironment) {
|
||||
throw new Error('Camera access is only available in native environment');
|
||||
}
|
||||
const result = await appBridge.safeCall(() => appBridge.openCamera(options), '');
|
||||
return result || '';
|
||||
}, [isNativeEnvironment]);
|
||||
|
||||
const openGallery = useCallback(async (
|
||||
options?: { multiple?: boolean; maxCount?: number }
|
||||
): Promise<string[]> => {
|
||||
if (!isNativeEnvironment) {
|
||||
throw new Error('Gallery access is only available in native environment');
|
||||
}
|
||||
const result = await appBridge.safeCall(() => appBridge.openGallery(options), []);
|
||||
return result || [];
|
||||
}, [isNativeEnvironment]);
|
||||
|
||||
const getLocation = useCallback(async (
|
||||
options?: { enableHighAccuracy?: boolean; timeout?: number }
|
||||
): Promise<LocationInfo> => {
|
||||
if (!isNativeEnvironment) {
|
||||
// 웹 환경에서는 HTML5 Geolocation API 사용
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!navigator.geolocation) {
|
||||
reject(new Error('Geolocation is not supported'));
|
||||
return;
|
||||
}
|
||||
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
(position) => {
|
||||
resolve({
|
||||
latitude: position.coords.latitude,
|
||||
longitude: position.coords.longitude,
|
||||
accuracy: position.coords.accuracy,
|
||||
timestamp: position.timestamp
|
||||
});
|
||||
},
|
||||
(error) => reject(error),
|
||||
{
|
||||
enableHighAccuracy: options?.enableHighAccuracy || false,
|
||||
timeout: options?.timeout || 10000
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
const result = await appBridge.safeCall(() => appBridge.getLocation(options));
|
||||
if (!result) {
|
||||
throw new Error('Failed to get location');
|
||||
}
|
||||
return result;
|
||||
}, [isNativeEnvironment]);
|
||||
|
||||
const getContacts = useCallback(async (): Promise<ContactInfo[]> => {
|
||||
if (!isNativeEnvironment) {
|
||||
throw new Error('Contact access is only available in native environment');
|
||||
}
|
||||
const result = await appBridge.safeCall(() => appBridge.getContacts(), []);
|
||||
return result || [];
|
||||
}, [isNativeEnvironment]);
|
||||
*/
|
||||
const shareContent = useCallback(async (content: ShareContent): Promise<void> => {
|
||||
if (!isNativeEnvironment) {
|
||||
// 웹 환경에서는 Web Share API 사용 (지원되는 경우)
|
||||
if (navigator.share) {
|
||||
try {
|
||||
await navigator.share({
|
||||
title: content.title,
|
||||
text: content.text,
|
||||
...(content.url && { url: content.url })
|
||||
});
|
||||
return;
|
||||
} catch (error) {
|
||||
console.warn('Web Share API failed:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// 폴백: 클립보드에 복사
|
||||
const shareText = `${content.title}\n${content.text}${content.url ? `\n${content.url}` : ''}`;
|
||||
if (navigator.clipboard) {
|
||||
await navigator.clipboard.writeText(shareText);
|
||||
alert('클립보드에 복사되었습니다.');
|
||||
} else {
|
||||
throw new Error('Sharing is not supported');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
return appBridge.safeCall(() => appBridge.shareContent(content));
|
||||
}, [isNativeEnvironment]);
|
||||
|
||||
return {
|
||||
isNativeEnvironment,
|
||||
isAndroid,
|
||||
isIOS,
|
||||
deviceInfo,
|
||||
navigateBack,
|
||||
navigateTo,
|
||||
navigateToLogin,
|
||||
closeWebView,
|
||||
showToast,
|
||||
showAlert,
|
||||
showConfirm,
|
||||
setStorage,
|
||||
// getStorage,
|
||||
removeStorage,
|
||||
/*
|
||||
openCamera,
|
||||
openGallery,
|
||||
getLocation,
|
||||
getContacts,
|
||||
*/
|
||||
shareContent
|
||||
};
|
||||
};
|
||||
173
src/hooks/useAuth.tsx
Normal file
173
src/hooks/useAuth.tsx
Normal file
@@ -0,0 +1,173 @@
|
||||
import React, { createContext, useContext, useState, useEffect, useCallback, ReactNode } from 'react';
|
||||
import { AuthContextType, AuthState, LoginCredentials, RegisterData, UserRole } from '@/types';
|
||||
import { authService } from '@/utils/auth';
|
||||
|
||||
const AuthContext = createContext<AuthContextType | null>(null);
|
||||
|
||||
interface AuthProviderProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export const AuthProvider: React.FC<AuthProviderProps> = ({ children }) => {
|
||||
const [authState, setAuthState] = useState<AuthState>({
|
||||
isAuthenticated: false,
|
||||
user: null,
|
||||
token: null,
|
||||
isLoading: true,
|
||||
});
|
||||
|
||||
const login = async (credentials: LoginCredentials): Promise<void> => {
|
||||
try {
|
||||
setAuthState(prev => ({ ...prev, isLoading: true }));
|
||||
|
||||
const result = await authService.login(credentials);
|
||||
|
||||
setAuthState({
|
||||
isAuthenticated: true,
|
||||
user: result.user,
|
||||
token: result.accessToken,
|
||||
isLoading: false,
|
||||
});
|
||||
} catch (error) {
|
||||
setAuthState(prev => ({ ...prev, isLoading: false }));
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const register = async (data: RegisterData): Promise<void> => {
|
||||
try {
|
||||
setAuthState(prev => ({ ...prev, isLoading: true }));
|
||||
|
||||
const result = await authService.register(data);
|
||||
|
||||
setAuthState({
|
||||
isAuthenticated: true,
|
||||
user: result.user,
|
||||
token: result.accessToken,
|
||||
isLoading: false,
|
||||
});
|
||||
} catch (error) {
|
||||
setAuthState(prev => ({ ...prev, isLoading: false }));
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const logout = useCallback((): void => {
|
||||
authService.logout();
|
||||
setAuthState({
|
||||
isAuthenticated: false,
|
||||
user: null,
|
||||
token: null,
|
||||
isLoading: false,
|
||||
});
|
||||
}, []);
|
||||
|
||||
const refreshToken = useCallback(async (): Promise<void> => {
|
||||
try {
|
||||
const newToken = await authService.refreshToken();
|
||||
const user = authService.getCurrentUserFromToken();
|
||||
|
||||
setAuthState(prev => ({
|
||||
...prev,
|
||||
token: newToken,
|
||||
user,
|
||||
isAuthenticated: true,
|
||||
}));
|
||||
} catch (error) {
|
||||
logout();
|
||||
throw error;
|
||||
}
|
||||
}, [logout]);
|
||||
|
||||
const hasRole = (role: UserRole): boolean => {
|
||||
return authService.hasRole(role);
|
||||
};
|
||||
|
||||
const hasPermission = (permission: string): boolean => {
|
||||
return authService.hasPermission(permission);
|
||||
};
|
||||
|
||||
const loadUserFromToken = useCallback(async (): Promise<void> => {
|
||||
try {
|
||||
if (authService.isAuthenticated()) {
|
||||
const user = authService.getCurrentUserFromToken();
|
||||
const token = authService.getAccessToken();
|
||||
|
||||
if (user && token) {
|
||||
setAuthState({
|
||||
isAuthenticated: true,
|
||||
user,
|
||||
token,
|
||||
isLoading: false,
|
||||
});
|
||||
|
||||
// 토큰 갱신 체크
|
||||
if (authService.shouldRefreshToken()) {
|
||||
await refreshToken();
|
||||
}
|
||||
} else {
|
||||
// 토큰이 유효하지 않으면 최신 사용자 정보 가져오기
|
||||
const currentUser = await authService.getCurrentUser();
|
||||
setAuthState({
|
||||
isAuthenticated: true,
|
||||
user: currentUser,
|
||||
token,
|
||||
isLoading: false,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
setAuthState({
|
||||
isAuthenticated: false,
|
||||
user: null,
|
||||
token: null,
|
||||
isLoading: false,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load user from token:', error);
|
||||
logout();
|
||||
}
|
||||
}, [refreshToken, logout]);
|
||||
|
||||
useEffect(() => {
|
||||
loadUserFromToken();
|
||||
}, [loadUserFromToken]);
|
||||
|
||||
// 토큰 자동 갱신 체크
|
||||
useEffect(() => {
|
||||
if(!authState.isAuthenticated){
|
||||
return () => {};
|
||||
}
|
||||
|
||||
const checkTokenExpiration = setInterval(() => {
|
||||
if (authService.shouldRefreshToken()) {
|
||||
refreshToken().catch(() => {
|
||||
logout();
|
||||
});
|
||||
}
|
||||
}, 60000); // 1분마다 체크
|
||||
|
||||
return () => clearInterval(checkTokenExpiration);
|
||||
}, [authState.isAuthenticated, refreshToken, logout]);
|
||||
|
||||
const contextValue: AuthContextType = {
|
||||
...authState,
|
||||
login,
|
||||
register,
|
||||
logout,
|
||||
refreshToken,
|
||||
hasRole,
|
||||
hasPermission,
|
||||
};
|
||||
|
||||
return <AuthContext.Provider value={contextValue}>{children}</AuthContext.Provider>;
|
||||
};
|
||||
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
export const useAuth = (): AuthContextType => {
|
||||
const context = useContext(AuthContext);
|
||||
if (!context) {
|
||||
throw new Error('useAuth must be used within an AuthProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
237
src/hooks/useLoginForm.tsx
Normal file
237
src/hooks/useLoginForm.tsx
Normal file
@@ -0,0 +1,237 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { LoginCredentials, RegisterData } from '@/types';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { t } from 'i18next';
|
||||
|
||||
interface FormErrors {
|
||||
email?: string;
|
||||
password?: string;
|
||||
name?: string;
|
||||
phone?: string;
|
||||
confirmPassword?: string;
|
||||
general?: string;
|
||||
}
|
||||
|
||||
interface UseLoginFormReturn {
|
||||
formData: LoginCredentials;
|
||||
errors: FormErrors;
|
||||
isLoading: boolean;
|
||||
handleChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
handleSubmit: (onSubmit: (data: LoginCredentials) => Promise<void>) => (e: React.FormEvent) => Promise<void>;
|
||||
resetForm: () => void;
|
||||
setError: (field: keyof FormErrors, message: string) => void;
|
||||
clearErrors: () => void;
|
||||
}
|
||||
|
||||
interface UseRegisterFormReturn {
|
||||
formData: RegisterData & { confirmPassword: string };
|
||||
errors: FormErrors;
|
||||
isLoading: boolean;
|
||||
handleChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
handleSubmit: (onSubmit: (data: RegisterData) => Promise<void>) => (e: React.FormEvent) => Promise<void>;
|
||||
resetForm: () => void;
|
||||
setError: (field: keyof FormErrors, message: string) => void;
|
||||
clearErrors: () => void;
|
||||
}
|
||||
|
||||
export const useLoginForm = (): UseLoginFormReturn => {
|
||||
const { t } = useTranslation();
|
||||
const [formData, setFormData] = useState<LoginCredentials>({
|
||||
email: '',
|
||||
password: '',
|
||||
});
|
||||
const [errors, setErrors] = useState<FormErrors>({});
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const validateLoginForm = useCallback((data: LoginCredentials): FormErrors => {
|
||||
const newErrors: FormErrors = {};
|
||||
|
||||
if (!data.email.trim()) {
|
||||
newErrors.email = t('login.emailPlaceholder');
|
||||
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(data.email)) {
|
||||
newErrors.email = t('login.emailPlaceholder');
|
||||
}
|
||||
|
||||
if (!data.password.trim()) {
|
||||
newErrors.password = t('login.passwordPlaceholder');
|
||||
} else if (data.password.length < 6) {
|
||||
newErrors.password = t('login.passwordMinLength');
|
||||
}
|
||||
|
||||
return newErrors;
|
||||
}, [t]);
|
||||
|
||||
const handleChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const { name, value } = e.target;
|
||||
setFormData(prev => ({ ...prev, [name]: value }));
|
||||
|
||||
// 해당 필드의 에러 제거
|
||||
if (errors[name as keyof FormErrors]) {
|
||||
setErrors(prev => ({ ...prev, [name]: undefined }));
|
||||
}
|
||||
}, [errors]);
|
||||
|
||||
const handleSubmit = useCallback((onSubmit: (data: LoginCredentials) => Promise<void>) => {
|
||||
return async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
const validationErrors = validateLoginForm(formData);
|
||||
if (Object.keys(validationErrors).length > 0) {
|
||||
setErrors(validationErrors);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
setErrors({});
|
||||
|
||||
try {
|
||||
await onSubmit(formData);
|
||||
} catch (error: unknown) {
|
||||
const errorMessage = error instanceof Error ? error.message : t('login.generalError');
|
||||
setErrors({ general: errorMessage });
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
}, [formData, validateLoginForm, t]);
|
||||
|
||||
const resetForm = useCallback(() => {
|
||||
setFormData({ email: '', password: '' });
|
||||
setErrors({});
|
||||
setIsLoading(false);
|
||||
}, []);
|
||||
|
||||
const setError = useCallback((field: keyof FormErrors, message: string) => {
|
||||
setErrors(prev => ({ ...prev, [field]: message }));
|
||||
}, []);
|
||||
|
||||
const clearErrors = useCallback(() => {
|
||||
setErrors({});
|
||||
}, []);
|
||||
|
||||
return {
|
||||
formData,
|
||||
errors,
|
||||
isLoading,
|
||||
handleChange,
|
||||
handleSubmit,
|
||||
resetForm,
|
||||
setError,
|
||||
clearErrors,
|
||||
};
|
||||
};
|
||||
|
||||
export const useRegisterForm = (): UseRegisterFormReturn => {
|
||||
const [formData, setFormData] = useState<RegisterData & { confirmPassword: string }>({
|
||||
email: '',
|
||||
password: '',
|
||||
name: '',
|
||||
phone: '',
|
||||
confirmPassword: '',
|
||||
});
|
||||
const [errors, setErrors] = useState<FormErrors>({});
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const validateRegisterForm = useCallback((data: RegisterData & { confirmPassword: string }): FormErrors => {
|
||||
const newErrors: FormErrors = {};
|
||||
|
||||
if (!data.email.trim()) {
|
||||
newErrors.email = '이메일을 입력해주세요.';
|
||||
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(data.email)) {
|
||||
newErrors.email = '올바른 이메일 형식을 입력해주세요.';
|
||||
}
|
||||
|
||||
if (!data.password.trim()) {
|
||||
newErrors.password = t('login.passwordPlaceholder');
|
||||
} else if (data.password.length < 8) {
|
||||
newErrors.password = t('login.passwordMinLength');
|
||||
} else if (!/(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/.test(data.password)) {
|
||||
newErrors.password = t('login.passwordComplexity');
|
||||
}
|
||||
|
||||
if (!data.confirmPassword.trim()) {
|
||||
newErrors.confirmPassword = t('login.confirmPasswordPlaceholder');
|
||||
} else if (data.password !== data.confirmPassword) {
|
||||
newErrors.confirmPassword = t('login.confirmPasswordMismatch');
|
||||
}
|
||||
|
||||
if (!data.name.trim()) {
|
||||
newErrors.name = t('login.namePlaceholder');
|
||||
} else if (data.name.length < 2) {
|
||||
newErrors.name = t('login.nameMinLength');
|
||||
}
|
||||
|
||||
if (data.phone && !/^[0-9-+\s()]+$/.test(data.phone)) {
|
||||
newErrors.phone = t('login.phoneFormat');
|
||||
}
|
||||
|
||||
return newErrors;
|
||||
}, []);
|
||||
|
||||
const handleChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const { name, value } = e.target;
|
||||
setFormData(prev => ({ ...prev, [name]: value }));
|
||||
|
||||
// 해당 필드의 에러 제거
|
||||
if (errors[name as keyof FormErrors]) {
|
||||
setErrors(prev => ({ ...prev, [name]: undefined }));
|
||||
}
|
||||
}, [errors]);
|
||||
|
||||
const handleSubmit = useCallback((onSubmit: (data: RegisterData) => Promise<void>) => {
|
||||
return async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
const validationErrors = validateRegisterForm(formData);
|
||||
if (Object.keys(validationErrors).length > 0) {
|
||||
setErrors(validationErrors);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
setErrors({});
|
||||
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const { confirmPassword: _confirmPassword, ...registerData } = formData;
|
||||
await onSubmit(registerData);
|
||||
} catch (error: unknown) {
|
||||
const errorMessage = error instanceof Error ? error.message : '회원가입 중 오류가 발생했습니다.';
|
||||
setErrors({ general: errorMessage });
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
}, [formData, validateRegisterForm]);
|
||||
|
||||
const resetForm = useCallback(() => {
|
||||
setFormData({
|
||||
email: '',
|
||||
password: '',
|
||||
name: '',
|
||||
phone: '',
|
||||
confirmPassword: '',
|
||||
});
|
||||
setErrors({});
|
||||
setIsLoading(false);
|
||||
}, []);
|
||||
|
||||
const setError = useCallback((field: keyof FormErrors, message: string) => {
|
||||
setErrors(prev => ({ ...prev, [field]: message }));
|
||||
}, []);
|
||||
|
||||
const clearErrors = useCallback(() => {
|
||||
setErrors({});
|
||||
}, []);
|
||||
|
||||
return {
|
||||
formData,
|
||||
errors,
|
||||
isLoading,
|
||||
handleChange,
|
||||
handleSubmit,
|
||||
resetForm,
|
||||
setError,
|
||||
clearErrors,
|
||||
};
|
||||
};
|
||||
119
src/hooks/useScrollDirection.tsx
Normal file
119
src/hooks/useScrollDirection.tsx
Normal file
@@ -0,0 +1,119 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
|
||||
export const useScrollDirection = () => {
|
||||
const [scrollDirection, setScrollDirection] = useState<'up' | 'down'>('up');
|
||||
const [isVisible, setIsVisible] = useState(true);
|
||||
const [translateY, setTranslateY] = useState(0);
|
||||
const [isScrollingState, setIsScrollingState] = useState(false);
|
||||
|
||||
const lastScrollY = useRef(0);
|
||||
const scrollAccumulator = useRef(0);
|
||||
const isScrolling = useRef(false);
|
||||
const scrollTimeout = useRef<NodeJS.Timeout | null>(null);
|
||||
const lastScrollDirection = useRef<'up' | 'down'>('up');
|
||||
|
||||
useEffect(() => {
|
||||
let ticking = false;
|
||||
|
||||
const updateScrollDirection = () => {
|
||||
const scrollY = window.scrollY;
|
||||
const scrollDiff = scrollY - lastScrollY.current;
|
||||
|
||||
// 스크롤 차이가 너무 작으면 무시
|
||||
if (Math.abs(scrollDiff) < 2) {
|
||||
ticking = false;
|
||||
return;
|
||||
}
|
||||
|
||||
isScrolling.current = true;
|
||||
setIsScrollingState(true);
|
||||
|
||||
// 스크롤 방향 설정 및 마지막 방향 저장
|
||||
if (scrollDiff > 0) {
|
||||
setScrollDirection('down');
|
||||
lastScrollDirection.current = 'down';
|
||||
} else {
|
||||
setScrollDirection('up');
|
||||
lastScrollDirection.current = 'up';
|
||||
}
|
||||
|
||||
// 스크롤 누적량 계산 (최대 100px까지) - 부드러운 보간
|
||||
const dampingFactor = 0.8; // 댐핑 팩터로 부드러운 움직임
|
||||
scrollAccumulator.current += scrollDiff * dampingFactor;
|
||||
scrollAccumulator.current = Math.max(-100, Math.min(100, scrollAccumulator.current));
|
||||
|
||||
// 드래그 중에는 스크롤 누적량에 비례하여 탭바 위치 조정
|
||||
let newTranslateY = 0;
|
||||
if (scrollAccumulator.current > 0) {
|
||||
// 아래로 스크롤: 0 ~ 100px 사이에서 부드럽게 조정
|
||||
newTranslateY = Math.min(100, scrollAccumulator.current);
|
||||
} else {
|
||||
// 위로 스크롤: 0px 고정하고 누적량도 0으로 리셋
|
||||
newTranslateY = 0;
|
||||
scrollAccumulator.current = Math.max(0, scrollAccumulator.current);
|
||||
}
|
||||
|
||||
// 부드러운 업데이트를 위한 RAF 사용
|
||||
requestAnimationFrame(() => {
|
||||
setTranslateY(newTranslateY);
|
||||
});
|
||||
|
||||
lastScrollY.current = scrollY > 0 ? scrollY : 0;
|
||||
|
||||
// 스크롤 종료 감지
|
||||
if (scrollTimeout.current) {
|
||||
clearTimeout(scrollTimeout.current);
|
||||
}
|
||||
|
||||
scrollTimeout.current = setTimeout(() => {
|
||||
isScrolling.current = false;
|
||||
setIsScrollingState(false);
|
||||
|
||||
// 스크롤 종료 시 마지막 드래그 방향에 따라 최종 위치 결정
|
||||
if (Math.abs(scrollAccumulator.current) > 15) {
|
||||
// 충분한 스크롤이 있었을 때만 방향에 따라 결정
|
||||
if (lastScrollDirection.current === 'down') {
|
||||
// 아래로 스크롤했으면 탭바 숨기기
|
||||
setTranslateY(100);
|
||||
setIsVisible(false);
|
||||
scrollAccumulator.current = 100;
|
||||
} else {
|
||||
// 위로 스크롤했으면 탭바 보이기
|
||||
setTranslateY(0);
|
||||
setIsVisible(true);
|
||||
scrollAccumulator.current = 0;
|
||||
}
|
||||
} else {
|
||||
// 스크롤이 미미할 때는 현재 상태 유지
|
||||
if (isVisible) {
|
||||
setTranslateY(0);
|
||||
scrollAccumulator.current = 0;
|
||||
} else {
|
||||
setTranslateY(100);
|
||||
scrollAccumulator.current = 100;
|
||||
}
|
||||
}
|
||||
}, 150);
|
||||
|
||||
ticking = false;
|
||||
};
|
||||
|
||||
const onScroll = () => {
|
||||
if (!ticking) {
|
||||
requestAnimationFrame(updateScrollDirection);
|
||||
ticking = true;
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('scroll', onScroll, { passive: true });
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('scroll', onScroll);
|
||||
if (scrollTimeout.current) {
|
||||
clearTimeout(scrollTimeout.current);
|
||||
}
|
||||
};
|
||||
}, [isVisible]);
|
||||
|
||||
return { scrollDirection, isVisible, translateY, isScrollingState };
|
||||
};
|
||||
138
src/hooks/useSearchFilter.tsx
Normal file
138
src/hooks/useSearchFilter.tsx
Normal file
@@ -0,0 +1,138 @@
|
||||
import { useState, useCallback, useEffect, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
SearchFilter,
|
||||
DEFAULT_SEARCH_FILTER,
|
||||
getDateRangeFromPeriod,
|
||||
getFilterDisplayText
|
||||
} from '@/types/filter';
|
||||
|
||||
interface UseSearchFilterOptions {
|
||||
initialFilter?: SearchFilter;
|
||||
storageKey?: string; // 로컬 스토리지에 저장할 키
|
||||
onFilterChange?: (filter: SearchFilter) => void;
|
||||
}
|
||||
|
||||
interface UseSearchFilterReturn {
|
||||
filter: SearchFilter;
|
||||
setFilter: (filter: SearchFilter) => void;
|
||||
resetFilter: () => void;
|
||||
isModalOpen: boolean;
|
||||
openModal: () => void;
|
||||
closeModal: () => void;
|
||||
handleFilterConfirm: (newFilter: SearchFilter) => void;
|
||||
getDisplayText: () => string;
|
||||
isFilterActive: boolean; // 기본값과 다른지 확인
|
||||
}
|
||||
|
||||
export const useSearchFilter = (options: UseSearchFilterOptions = {}): UseSearchFilterReturn => {
|
||||
const { t } = useTranslation();
|
||||
const {
|
||||
initialFilter = DEFAULT_SEARCH_FILTER,
|
||||
storageKey,
|
||||
onFilterChange
|
||||
} = options;
|
||||
|
||||
const isInitialMount = useRef(true);
|
||||
|
||||
// 로컬 스토리지에서 저장된 필터 로드
|
||||
const loadStoredFilter = useCallback((): SearchFilter => {
|
||||
if (!storageKey) return initialFilter;
|
||||
|
||||
try {
|
||||
const stored = localStorage.getItem(storageKey);
|
||||
if (stored) {
|
||||
const parsedFilter = JSON.parse(stored);
|
||||
// Date 객체 복원
|
||||
if (parsedFilter.dateRange) {
|
||||
parsedFilter.dateRange = {
|
||||
startDate: new Date(parsedFilter.dateRange.startDate),
|
||||
endDate: new Date(parsedFilter.dateRange.endDate)
|
||||
};
|
||||
}
|
||||
return { ...initialFilter, ...parsedFilter };
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to load stored filter:', error);
|
||||
}
|
||||
|
||||
return initialFilter;
|
||||
}, [initialFilter, storageKey]);
|
||||
|
||||
const [filter, setFilterState] = useState<SearchFilter>(loadStoredFilter);
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
|
||||
// 필터 저장
|
||||
const saveFilter = useCallback((newFilter: SearchFilter) => {
|
||||
if (storageKey) {
|
||||
try {
|
||||
localStorage.setItem(storageKey, JSON.stringify(newFilter));
|
||||
} catch (error) {
|
||||
console.warn('Failed to save filter to storage:', error);
|
||||
}
|
||||
}
|
||||
}, [storageKey]);
|
||||
|
||||
// 필터 설정
|
||||
const setFilter = useCallback((newFilter: SearchFilter) => {
|
||||
// 날짜 범위가 없고 period가 custom이 아닌 경우 자동 계산
|
||||
const finalFilter = {
|
||||
...newFilter,
|
||||
dateRange: newFilter.dateRange || getDateRangeFromPeriod(newFilter.period)
|
||||
};
|
||||
|
||||
//setFilterState(finalFilter);
|
||||
//saveFilter(finalFilter);
|
||||
//onFilterChange?.(finalFilter);
|
||||
}, [saveFilter, onFilterChange]);
|
||||
|
||||
// 필터 초기화
|
||||
const resetFilter = useCallback(() => {
|
||||
setFilter(initialFilter);
|
||||
}, [initialFilter, setFilter]);
|
||||
|
||||
// 모달 제어
|
||||
const openModal = useCallback(() => {
|
||||
setIsModalOpen(true);
|
||||
}, []);
|
||||
|
||||
const closeModal = useCallback(() => {
|
||||
setIsModalOpen(false);
|
||||
}, []);
|
||||
|
||||
// 모달에서 확인 버튼 클릭 시
|
||||
const handleFilterConfirm = useCallback((newFilter: SearchFilter) => {
|
||||
setFilter(newFilter);
|
||||
closeModal();
|
||||
}, [setFilter, closeModal]);
|
||||
|
||||
// 표시 텍스트 가져오기
|
||||
const getDisplayText = useCallback(() => {
|
||||
return getFilterDisplayText(filter, t);
|
||||
}, [filter, t]);
|
||||
|
||||
// 기본값과 다른지 확인
|
||||
const isFilterActive = useCallback(() => {
|
||||
return JSON.stringify(filter) !== JSON.stringify(DEFAULT_SEARCH_FILTER);
|
||||
}, [filter])();
|
||||
|
||||
// 초기 로드 시 필터 변경 콜백 호출
|
||||
useEffect(() => {
|
||||
if (isInitialMount.current && onFilterChange) {
|
||||
onFilterChange(filter);
|
||||
isInitialMount.current = false;
|
||||
}
|
||||
}, [filter, onFilterChange]);
|
||||
|
||||
return {
|
||||
filter,
|
||||
setFilter,
|
||||
resetFilter,
|
||||
isModalOpen,
|
||||
openModal,
|
||||
closeModal,
|
||||
handleFilterConfirm,
|
||||
getDisplayText,
|
||||
isFilterActive
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user