feat(frontend): Phase 3 - Zustand 스토어 구현

- useAuthStore: 인증 상태 관리 (localStorage persist)
- useScheduleStore: 스케줄 페이지 상태 (검색, 필터, 날짜, 뷰)
- useUIStore: UI 상태 (토스트, 모달, 라이트박스, 확인 다이얼로그)
- stores/index.js: 통합 export

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
caadiq 2026-01-21 17:11:00 +09:00
parent dc63a91f4f
commit cba7e4b522
6 changed files with 359 additions and 17 deletions

View file

@ -1,20 +1,31 @@
import { BrowserRouter, Routes, Route } from "react-router-dom";
import { isMobile } from "react-device-detect";
import { cn, getTodayKST, formatFullDate, formatXDateTime } from "@/utils";
import { CATEGORY_NAMES, SOCIAL_LINKS } from "@/constants";
import { cn, getTodayKST, formatFullDate } from "@/utils";
import { useAuthStore, useScheduleStore, useUIStore } from "@/stores";
/**
* 프로미스나인 팬사이트 메인
*
* Phase 2: 유틸리티 상수 완료
* - constants/index.js: 상수 정의 (카테고리, SNS 링크 )
* - utils/cn.js: className 유틸리티 (clsx 기반)
* - utils/date.js: 날짜 관련 유틸리티
* - utils/format.js: 포맷팅 유틸리티
* - utils/index.js: 통합 export
* Phase 3: 스토어 완료
* - useAuthStore: 인증 상태 (localStorage 지속)
* - useScheduleStore: 스케줄 페이지 상태
* - useUIStore: UI 상태 (모달, 토스트, 라이트박스 )
*/
function App() {
const today = getTodayKST();
const { isAuthenticated, login, logout } = useAuthStore();
const { viewMode, setViewMode, selectedCategories, toggleCategory } = useScheduleStore();
const { showSuccess, showError, toasts } = useUIStore();
const handleTestLogin = () => {
login("test-token", { name: "테스트 유저" });
showSuccess("로그인 성공!");
};
const handleTestLogout = () => {
logout();
showError("로그아웃됨");
};
return (
<BrowserRouter>
@ -27,21 +38,64 @@ function App() {
<h1 className="text-2xl font-bold text-primary mb-2">
fromis_9 Frontend Refactoring
</h1>
<p className="text-gray-600">
Phase 2 완료 - 유틸리티 상수
</p>
<p className="text-gray-600">Phase 3 완료 - 스토어</p>
<p className={cn("text-sm", isMobile ? "text-blue-500" : "text-green-500")}>
디바이스: {isMobile ? "모바일" : "PC"}
</p>
<div className="mt-6 p-4 bg-white rounded-lg shadow text-left text-sm space-y-2">
<p><strong>오늘 날짜:</strong> {today}</p>
<p><strong>포맷된 날짜:</strong> {formatFullDate(today)}</p>
<p><strong>X 스타일:</strong> {formatXDateTime(today, "19:00")}</p>
<p><strong>카테고리:</strong> {Object.values(CATEGORY_NAMES).join(", ")}</p>
<p><strong>SNS 개수:</strong> {Object.keys(SOCIAL_LINKS).length}</p>
<div className="mt-6 p-4 bg-white rounded-lg shadow text-left text-sm space-y-3">
<p><strong>오늘:</strong> {formatFullDate(today)}</p>
<div className="border-t pt-3">
<p className="font-semibold mb-2">useAuthStore 테스트</p>
<p>인증 상태: {isAuthenticated ? "✅ 로그인됨" : "❌ 로그아웃"}</p>
<div className="flex gap-2 mt-2">
<button onClick={handleTestLogin} className="px-3 py-1 bg-primary text-white rounded text-xs">로그인</button>
<button onClick={handleTestLogout} className="px-3 py-1 bg-gray-500 text-white rounded text-xs">로그아웃</button>
</div>
</div>
<div className="border-t pt-3">
<p className="font-semibold mb-2">useScheduleStore 테스트</p>
<p> 모드: {viewMode}</p>
<div className="flex gap-2 mt-2">
<button onClick={() => setViewMode("list")} className={cn("px-3 py-1 rounded text-xs", viewMode === "list" ? "bg-primary text-white" : "bg-gray-200")}>리스트</button>
<button onClick={() => setViewMode("calendar")} className={cn("px-3 py-1 rounded text-xs", viewMode === "calendar" ? "bg-primary text-white" : "bg-gray-200")}>캘린더</button>
</div>
<p className="mt-2">선택된 카테고리: {selectedCategories.length > 0 ? selectedCategories.join(", ") : "없음"}</p>
<div className="flex gap-2 mt-2">
{[1, 2, 3].map((id) => (
<button key={id} onClick={() => toggleCategory(id)} className={cn("px-3 py-1 rounded text-xs", selectedCategories.includes(id) ? "bg-primary text-white" : "bg-gray-200")}>
카테고리 {id}
</button>
))}
</div>
</div>
<div className="border-t pt-3">
<p className="font-semibold mb-2">useUIStore 토스트</p>
<p>활성 토스트: {toasts.length}</p>
</div>
</div>
</div>
{/* 토스트 표시 */}
<div className="fixed bottom-4 right-4 space-y-2">
{toasts.map((toast) => (
<div
key={toast.id}
className={cn(
"px-4 py-2 rounded shadow-lg text-white text-sm",
toast.type === "success" && "bg-green-500",
toast.type === "error" && "bg-red-500",
toast.type === "warning" && "bg-yellow-500",
toast.type === "info" && "bg-blue-500"
)}
>
{toast.message}
</div>
))}
</div>
</div>
}
/>

View file

@ -0,0 +1,6 @@
/**
* 스토어 통합 export
*/
export { default as useAuthStore } from './useAuthStore';
export { default as useScheduleStore } from './useScheduleStore';
export { default as useUIStore } from './useUIStore';

View file

@ -0,0 +1,54 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
/**
* 인증 상태 스토어
* localStorage에 지속되어 새로고침 후에도 유지
*/
const useAuthStore = create(
persist(
(set, get) => ({
// 상태
token: null,
user: null,
isAuthenticated: false,
// 로그인
login: (token, user) => {
set({
token,
user,
isAuthenticated: true,
});
},
// 로그아웃
logout: () => {
set({
token: null,
user: null,
isAuthenticated: false,
});
},
// 토큰 가져오기
getToken: () => get().token,
// 인증 여부 확인
checkAuth: () => {
const { token } = get();
return !!token;
},
}),
{
name: 'auth-storage',
partialize: (state) => ({
token: state.token,
user: state.user,
isAuthenticated: state.isAuthenticated,
}),
}
)
);
export default useAuthStore;

View file

@ -0,0 +1,107 @@
import { create } from 'zustand';
/**
* 스케줄 페이지 상태 스토어
* 메모리 기반 - SPA 페이지 이동 유지, 새로고침 초기화
*/
const useScheduleStore = create((set, get) => ({
// ===== 검색 관련 =====
searchInput: '',
searchTerm: '',
isSearchMode: false,
// ===== 필터 관련 =====
selectedCategories: [],
selectedMembers: [],
// ===== 날짜 관련 =====
selectedDate: undefined, // undefined: 오늘, null: 전체, Date: 특정 날짜
currentDate: new Date(),
// ===== 뷰 관련 =====
viewMode: 'list', // 'list' | 'calendar'
scrollPosition: 0,
// ===== 검색 액션 =====
setSearchInput: (value) => set({ searchInput: value }),
setSearchTerm: (value) => set({ searchTerm: value }),
setIsSearchMode: (value) => set({ isSearchMode: value }),
startSearch: (term) => {
set({
searchTerm: term,
isSearchMode: true,
selectedDate: null, // 검색 시 날짜 필터 해제
});
},
clearSearch: () => {
set({
searchInput: '',
searchTerm: '',
isSearchMode: false,
});
},
// ===== 필터 액션 =====
setSelectedCategories: (value) => set({ selectedCategories: value }),
setSelectedMembers: (value) => set({ selectedMembers: value }),
toggleCategory: (categoryId) => {
const { selectedCategories } = get();
const isSelected = selectedCategories.includes(categoryId);
set({
selectedCategories: isSelected
? selectedCategories.filter((id) => id !== categoryId)
: [...selectedCategories, categoryId],
});
},
toggleMember: (memberId) => {
const { selectedMembers } = get();
const isSelected = selectedMembers.includes(memberId);
set({
selectedMembers: isSelected
? selectedMembers.filter((id) => id !== memberId)
: [...selectedMembers, memberId],
});
},
clearFilters: () => {
set({
selectedCategories: [],
selectedMembers: [],
});
},
// ===== 날짜 액션 =====
setSelectedDate: (value) => set({ selectedDate: value }),
setCurrentDate: (value) => set({ currentDate: value }),
goToToday: () => {
set({
selectedDate: undefined,
currentDate: new Date(),
});
},
// ===== 뷰 액션 =====
setViewMode: (mode) => set({ viewMode: mode }),
setScrollPosition: (value) => set({ scrollPosition: value }),
// ===== 전체 초기화 =====
reset: () =>
set({
searchInput: '',
searchTerm: '',
isSearchMode: false,
selectedCategories: [],
selectedMembers: [],
selectedDate: undefined,
currentDate: new Date(),
viewMode: 'list',
scrollPosition: 0,
}),
}));
export default useScheduleStore;

View file

@ -0,0 +1,121 @@
import { create } from 'zustand';
/**
* UI 상태 스토어
* 모달, 토스트, 사이드바 전역 UI 상태 관리
*/
const useUIStore = create((set, get) => ({
// ===== 모달 =====
modalOpen: false,
modalContent: null,
modalProps: {},
openModal: (content, props = {}) => {
set({
modalOpen: true,
modalContent: content,
modalProps: props,
});
},
closeModal: () => {
set({
modalOpen: false,
modalContent: null,
modalProps: {},
});
},
// ===== 토스트 =====
toasts: [],
addToast: (message, type = 'info', duration = 3000) => {
const id = Date.now();
const toast = { id, message, type, duration };
set((state) => ({
toasts: [...state.toasts, toast],
}));
// 자동 제거
if (duration > 0) {
setTimeout(() => {
get().removeToast(id);
}, duration);
}
return id;
},
removeToast: (id) => {
set((state) => ({
toasts: state.toasts.filter((t) => t.id !== id),
}));
},
clearToasts: () => set({ toasts: [] }),
// 편의 메서드
showSuccess: (message, duration) => get().addToast(message, 'success', duration),
showError: (message, duration) => get().addToast(message, 'error', duration),
showWarning: (message, duration) => get().addToast(message, 'warning', duration),
showInfo: (message, duration) => get().addToast(message, 'info', duration),
// ===== 사이드바 (모바일) =====
sidebarOpen: false,
toggleSidebar: () => set((state) => ({ sidebarOpen: !state.sidebarOpen })),
openSidebar: () => set({ sidebarOpen: true }),
closeSidebar: () => set({ sidebarOpen: false }),
// ===== 라이트박스 =====
lightboxOpen: false,
lightboxImages: [],
lightboxIndex: 0,
openLightbox: (images, index = 0) => {
set({
lightboxOpen: true,
lightboxImages: Array.isArray(images) ? images : [images],
lightboxIndex: index,
});
},
closeLightbox: () => {
set({
lightboxOpen: false,
lightboxImages: [],
lightboxIndex: 0,
});
},
setLightboxIndex: (index) => set({ lightboxIndex: index }),
// ===== 로딩 =====
globalLoading: false,
setGlobalLoading: (value) => set({ globalLoading: value }),
// ===== 확인 다이얼로그 =====
confirmDialog: null,
showConfirm: (options) => {
return new Promise((resolve) => {
set({
confirmDialog: {
...options,
onConfirm: () => {
set({ confirmDialog: null });
resolve(true);
},
onCancel: () => {
set({ confirmDialog: null });
resolve(false);
},
},
});
});
},
closeConfirm: () => set({ confirmDialog: null }),
}));
export default useUIStore;