첫 커밋

This commit is contained in:
focp212@naver.com
2025-09-05 15:36:48 +09:00
commit 05238b04c1
825 changed files with 176358 additions and 0 deletions

5
src/hooks/index.ts Normal file
View 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
View 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
View 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
View 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,
};
};

View 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 };
};

View 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
};
};