diff --git a/package-lock.json b/package-lock.json index c8f5c70..a01150e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,6 +23,7 @@ "@tanstack/react-virtual": "^3.13.12", "@tosspayments/payment-widget-sdk": "^0.12.0", "@tosspayments/tosspayments-sdk": "^2.4.0", + "@vercel/speed-insights": "^1.2.0", "axios": "^1.11.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", @@ -10030,6 +10031,41 @@ "win32" ] }, + "node_modules/@vercel/speed-insights": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@vercel/speed-insights/-/speed-insights-1.2.0.tgz", + "integrity": "sha512-y9GVzrUJ2xmgtQlzFP2KhVRoCglwfRQgjyfY607aU0hh0Un6d0OUyrJkjuAlsV18qR4zfoFPs/BiIj9YDS6Wzw==", + "hasInstallScript": true, + "license": "Apache-2.0", + "peerDependencies": { + "@sveltejs/kit": "^1 || ^2", + "next": ">= 13", + "react": "^18 || ^19 || ^19.0.0-rc", + "svelte": ">= 4", + "vue": "^3", + "vue-router": "^4" + }, + "peerDependenciesMeta": { + "@sveltejs/kit": { + "optional": true + }, + "next": { + "optional": true + }, + "react": { + "optional": true + }, + "svelte": { + "optional": true + }, + "vue": { + "optional": true + }, + "vue-router": { + "optional": true + } + } + }, "node_modules/@vitest/expect": { "version": "0.34.7", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-0.34.7.tgz", diff --git a/package.json b/package.json index f0f6f17..6c6cf3e 100644 --- a/package.json +++ b/package.json @@ -42,6 +42,7 @@ "@tanstack/react-virtual": "^3.13.12", "@tosspayments/payment-widget-sdk": "^0.12.0", "@tosspayments/tosspayments-sdk": "^2.4.0", + "@vercel/speed-insights": "^1.2.0", "axios": "^1.11.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", diff --git a/src/app/layout.tsx b/src/app/layout.tsx index f04898d..76857cd 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,6 +1,7 @@ import type { Metadata } from 'next'; import { Suspense } from 'react'; import { pretendard } from '@/lib/fonts'; +import { SpeedInsights } from '@vercel/speed-insights/next'; import ClientLayout from '@/components/common/ClientLayout'; import './globals.css'; @@ -70,6 +71,7 @@ export default function RootLayout({ {children} + ); diff --git a/src/store/likeStore.ts b/src/store/likeStore.ts index 4ea1591..a90d965 100644 --- a/src/store/likeStore.ts +++ b/src/store/likeStore.ts @@ -1,101 +1,99 @@ import { create } from 'zustand'; +import { persist, createJSONStorage } from 'zustand/middleware'; import { Activity } from '@/types/activities.type'; interface FavoritesState { - favorites: Activity[]; - currentUserId: number | null; + favoritesByUser: Record; // 모든 사용자의 찜 목록을 저장 + currentUserId: number | null; // 현재 로그인한 사용자 ID (세션 상태) + favorites: Activity[]; // 현재 사용자의 찜 목록 (UI용) + initializeUser: (userId: number) => void; isFavorite: (activityId: number) => boolean; removeFavorite: (activityId: number) => void; toggleFavorite: (activity: Activity) => void; clearFavorites: () => void; } +export const useFavoritesStore = create()( + persist( + (set, get) => ({ + favoritesByUser: {}, + currentUserId: null, + favorites: [], -// 사용자별 로컬 키 생성 -const getUserStorageKey = (userId: number) => `favorites-${userId}`; + initializeUser: (userId) => { + const { currentUserId, favoritesByUser } = get(); + // 이미 같은 사용자로 초기화된 경우 중복 실행 방지 + if (currentUserId === userId) return; -// 사용자별 찜 목록 불러오기 -const loadUserFavorites = (userId: number): Activity[] => { - try { - const key = getUserStorageKey(userId); - const saved = localStorage.getItem(key); - return saved ? JSON.parse(saved) : []; - } catch (error) { - console.error('찜 목록 불러오기 실패:', error); - return []; - } -}; + // `favoritesByUser`에서 현재 사용자의 찜 목록을 가져와 UI용 상태에 설정 + const userFavorites = favoritesByUser[userId] || []; + set({ + currentUserId: userId, + favorites: userFavorites, + }); + }, -// 사용자별 찜 목록 저장 -const saveUserFavorites = (userId: number, favorites: Activity[]) => { - try { - const key = getUserStorageKey(userId); - localStorage.setItem(key, JSON.stringify(favorites)); - } catch (error) { - console.error('찜 목록 저장 실패:', error); - } -}; + isFavorite: (activityId) => { + // 현재 사용자의 찜 목록에서 확인 + return get().favorites.some((fav) => fav.id === activityId); + }, -export const useFavoritesStore = create()((set, get) => ({ - favorites: [], - currentUserId: null, - // 사용자 판별 - initializeUser: (userId) => { - const currentUserId = get().currentUserId; - // 이미 같은 사용자면 리턴 - if (currentUserId === userId) return; - // 새 사용자의 찜 목록 로드 - const userFavorites = loadUserFavorites(userId); - set({ - currentUserId: userId, - favorites: userFavorites, - }); - }, - // 찜되어 있는지 확인 - isFavorite: (activityId) => { - const state = get(); - return state.favorites.some((fav) => fav.id === activityId); - }, - // 찜목록에 제거 - removeFavorite: (activityId) => { - const { currentUserId } = get(); + removeFavorite: (activityId) => { + const { currentUserId, favorites } = get(); + if (!currentUserId) { + console.warn('사용자가 로그인되지 않았습니다.'); + return; + } - if (!currentUserId) { - console.warn('사용자가 로그인되지 않았습니다.'); - return; - } + const newFavorites = favorites.filter((fav) => fav.id !== activityId); - set((state) => { - const newFavorites = state.favorites.filter((fav) => fav.id !== activityId); - saveUserFavorites(currentUserId, newFavorites); - return { favorites: newFavorites }; - }); - }, - // 찜목록 토글 기능 - toggleFavorite: (activity) => { - const { currentUserId } = get(); + set((state) => ({ + favorites: newFavorites, // UI 상태 업데이트 + favoritesByUser: { + ...state.favoritesByUser, + [currentUserId]: newFavorites, // 영속성 상태 업데이트 + }, + })); + }, - if (!currentUserId) { - console.warn('사용자가 로그인되지 않았습니다.'); - return; - } + toggleFavorite: (activity) => { + const { currentUserId, favorites } = get(); + if (!currentUserId) { + console.warn('사용자가 로그인되지 않았습니다.'); + return; + } - let newFavorites; - set((state) => { - const isAlreadyFavorite = get().isFavorite(activity.id); - if (isAlreadyFavorite) { - newFavorites = state.favorites.filter((fav) => fav.id !== activity.id); - } else { - newFavorites = [...state.favorites, activity]; - } - saveUserFavorites(currentUserId, newFavorites); - return { favorites: newFavorites }; - }); - }, - clearFavorites: () => { - set({ - favorites: [], - currentUserId: null, - }); - }, -})); + const isAlreadyFavorite = get().isFavorite(activity.id); + const newFavorites = isAlreadyFavorite + ? favorites.filter((fav) => fav.id !== activity.id) + : [...favorites, activity]; + + set((state) => ({ + favorites: newFavorites, // UI 상태 업데이트 + favoritesByUser: { + ...state.favoritesByUser, + [currentUserId]: newFavorites, // 영속성 상태 업데이트 + }, + })); + }, + + clearFavorites: () => { + // 로그아웃 시 현재 사용자 정보만 초기화 + set({ + currentUserId: null, + favorites: [], + }); + }, + }), + { + name: 'favorites-storage', // localStorage에 저장될 고유한 키 + storage: createJSONStorage(() => localStorage), + // `favoritesByUser` 상태만 localStorage에 저장 + partialize: (state) => ({ favoritesByUser: state.favoritesByUser }), + // 스토리지에서 데이터를 성공적으로 불러왔을 때 실행 + onRehydrateStorage: () => () => { + console.log('찜 목록을 성공적으로 불러왔습니다.'); + }, + }, + ), +); diff --git a/src/store/recentlyWatched.ts b/src/store/recentlyWatched.ts index fc70ef2..378e89c 100644 --- a/src/store/recentlyWatched.ts +++ b/src/store/recentlyWatched.ts @@ -1,5 +1,6 @@ import { create } from 'zustand'; import { Activity } from '@/types/activities.type'; +import { persist, createJSONStorage } from 'zustand/middleware'; interface ViewedActivity extends Activity { viewedAt: string; // ISO string @@ -16,23 +17,6 @@ interface RecentViewedState { const WEEK_MS = 7 * 24 * 60 * 60 * 1000; const STORAGE_KEY = 'recent-viewed'; -const loadRecent = (): ViewedActivity[] => { - try { - const saved = localStorage.getItem(STORAGE_KEY); - return saved ? JSON.parse(saved) : []; - } catch { - return []; - } -}; - -const saveRecent = (items: ViewedActivity[]) => { - try { - localStorage.setItem(STORAGE_KEY, JSON.stringify(items)); - } catch (e) { - console.error('최근 본 목록 저장 실패:', e); - } -}; - const formatDateLabel = (date: Date) => { const today = new Date(); const yesterday = new Date(Date.now() - 86400000); @@ -64,38 +48,46 @@ const groupByLabel = (items: ViewedActivity[]) => { return groups; }; -export const useRecentViewedStore = create((set) => { - const initial = loadRecent(); - return { - recentViewed: initial, - grouped: groupByLabel(initial), - - addViewed: (activity) => { - const now = new Date(); - const weekAgo = now.getTime() - WEEK_MS; - - set((state) => { - const filtered = state.recentViewed.filter( - (a) => a.id !== activity.id && new Date(a.viewedAt).getTime() >= weekAgo, - ); - - const newItems = [{ ...activity, viewedAt: now.toISOString() }, ...filtered]; - - saveRecent(newItems); - return { recentViewed: newItems, grouped: groupByLabel(newItems) }; - }); - }, - - removeViewed: (activityId) => - set((state) => { - const newItems = state.recentViewed.filter((a) => a.id !== activityId); - saveRecent(newItems); - return { recentViewed: newItems, grouped: groupByLabel(newItems) }; - }), - - clearViewed: () => { - saveRecent([]); - set({ recentViewed: [], grouped: {} }); +export const useRecentViewedStore = create()( + persist( + (set) => ({ + recentViewed: [], + grouped: {}, + + addViewed: (activity) => { + const now = new Date(); + const weekAgo = now.getTime() - WEEK_MS; + + set((state) => { + const filtered = state.recentViewed.filter( + (a) => a.id !== activity.id && new Date(a.viewedAt).getTime() >= weekAgo, + ); + + const newItems = [{ ...activity, viewedAt: now.toISOString() }, ...filtered]; + + return { recentViewed: newItems, grouped: groupByLabel(newItems) }; + }); + }, + + removeViewed: (activityId) => + set((state) => { + const newItems = state.recentViewed.filter((a) => a.id !== activityId); + return { recentViewed: newItems, grouped: groupByLabel(newItems) }; + }), + + clearViewed: () => { + set({ recentViewed: [], grouped: {} }); + }, + }), + { + name: STORAGE_KEY, + storage: createJSONStorage(() => localStorage), + partialize: (state) => ({ recentViewed: state.recentViewed }), + onRehydrateStorage: () => (state) => { + if (state) { + state.grouped = groupByLabel(state.recentViewed); + } + }, }, - }; -}); + ), +);