diff --git a/week04/Jerry/src/App.css b/week04/Jerry/src/App.css new file mode 100644 index 0000000..e69de29 diff --git a/week04/Jerry/src/App.tsx b/week04/Jerry/src/App.tsx new file mode 100644 index 0000000..c2dc86a --- /dev/null +++ b/week04/Jerry/src/App.tsx @@ -0,0 +1,23 @@ +import { BrowserRouter, Routes, Route } from 'react-router-dom' +import MainPage from './pages/MainPage' +import Zustand from './pages/Zustand' +import Redux from './pages/Redux' +import ContextApi from './pages/ContextApi' +import TanstackQuery from './pages/TanstackQuery' + + +function App() { + return ( + + + } /> + } /> + } /> + } /> + } /> + + + ) +} + +export default App diff --git a/week04/Jerry/src/api/movies.ts b/week04/Jerry/src/api/movies.ts new file mode 100644 index 0000000..e9812ad --- /dev/null +++ b/week04/Jerry/src/api/movies.ts @@ -0,0 +1,23 @@ +import type { MovieListResponse } from "../types/movie" + +const BASE_URL = 'https://api.themoviedb.org/3' +const ACCESS_TOKEN = import.meta.env.VITE_TMDB_ACCESS_TOKEN + +const fetchMovies = async (endPoint: string, page = 1): Promise => { + const res = await fetch( + `${BASE_URL}/movie/${endPoint}?language=ko-KR&page=${page}`, + { + headers: { + Authorization: `Bearer ${ACCESS_TOKEN}`, + 'Content-Type': 'application/json', + }, + } + ); + if (!res.ok) throw new Error('Failed to fetch movies') + return res.json(); +} + +export const getPopularMovies = (page?: number) => fetchMovies('popular', page); +export const getNowPlayingMovies = (page?: number) => fetchMovies('now_playing', page); +export const getTopRatedMovies = (page?: number) => fetchMovies('top_rated', page); +export const getUpcomingMovies = (page?: number) => fetchMovies('upcoming', page); \ No newline at end of file diff --git a/week04/Jerry/src/assets/List.png b/week04/Jerry/src/assets/List.png new file mode 100644 index 0000000..fd2d923 Binary files /dev/null and b/week04/Jerry/src/assets/List.png differ diff --git a/week04/Jerry/src/assets/Logo.png b/week04/Jerry/src/assets/Logo.png new file mode 100644 index 0000000..93f5355 Binary files /dev/null and b/week04/Jerry/src/assets/Logo.png differ diff --git a/week04/Jerry/src/assets/hero.png b/week04/Jerry/src/assets/hero.png new file mode 100644 index 0000000..cc51a3d Binary files /dev/null and b/week04/Jerry/src/assets/hero.png differ diff --git a/week04/Jerry/src/assets/react.svg b/week04/Jerry/src/assets/react.svg new file mode 100644 index 0000000..6c87de9 --- /dev/null +++ b/week04/Jerry/src/assets/react.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/week04/Jerry/src/assets/vite.svg b/week04/Jerry/src/assets/vite.svg new file mode 100644 index 0000000..5101b67 --- /dev/null +++ b/week04/Jerry/src/assets/vite.svg @@ -0,0 +1 @@ +Vite diff --git a/week04/Jerry/src/context/SettingsContext.tsx b/week04/Jerry/src/context/SettingsContext.tsx new file mode 100644 index 0000000..cc5b61c --- /dev/null +++ b/week04/Jerry/src/context/SettingsContext.tsx @@ -0,0 +1,40 @@ +import { createContext, useContext, useState, PropsWithChildren } from 'react' + +type Theme = 'dark' | 'light' +type Language = 'ko' | 'en' + +interface Settings { + theme: Theme + language: Language + notifications: boolean + setTheme: (t: Theme) => void + setLanguage: (l: Language) => void + setNotifications: (n: boolean) => void + reset: () => void +} + +// 초기 상태 +const INITIAL = { theme: 'dark' as Theme, language: 'ko' as Language, notifications: true } + +const SettingsContext = createContext(null) + +export function SettingsProvider({ children }: PropsWithChildren) { + // 상태 말고 setTheme 이런 형식으로 설정 + const [theme, setTheme] = useState(INITIAL.theme) + const [language, setLanguage] = useState(INITIAL.language) + const [notifications, setNotifications] = useState(INITIAL.notifications) + + const reset = () => { + setTheme(INITIAL.theme) + setLanguage(INITIAL.language) + setNotifications(INITIAL.notifications) + } + + return ( + + {children} + + ) +} + +export const useSettings = () => useContext(SettingsContext)! diff --git a/week04/Jerry/src/hooks/queries/movieKeys.ts b/week04/Jerry/src/hooks/queries/movieKeys.ts new file mode 100644 index 0000000..ae2fb86 --- /dev/null +++ b/week04/Jerry/src/hooks/queries/movieKeys.ts @@ -0,0 +1,14 @@ +export const movieKeys = { + all: ['movies'] as const, + lists: () => [...movieKeys.all, 'list'] as const, + + popular: (page: number) => [...movieKeys.lists(), 'popular', page] as const, + nowPlaying: (page: number) => [...movieKeys.lists(), 'nowPlaying', page] as const, + topRated: (page: number) => [...movieKeys.lists(), 'topRated', page] as const, + upcoming: (page: number) => [...movieKeys.lists(), 'upcoming', page] as const, + + popularInfinite: () => [...movieKeys.lists(), 'popular', 'infinite'] as const, + nowPlayingInfinite: () => [...movieKeys.lists(), 'nowPlaying', 'infinite'] as const, + topRatedInfinite: () => [...movieKeys.lists(), 'topRated', 'infinite'] as const, + upcomingInfinite: () => [...movieKeys.lists(), 'upcoming', 'infinite'] as const, +}; \ No newline at end of file diff --git a/week04/Jerry/src/hooks/queries/useGetInfiniteMovies.ts b/week04/Jerry/src/hooks/queries/useGetInfiniteMovies.ts new file mode 100644 index 0000000..b3c8171 --- /dev/null +++ b/week04/Jerry/src/hooks/queries/useGetInfiniteMovies.ts @@ -0,0 +1,39 @@ +import { useInfiniteQuery } from "@tanstack/react-query"; +import { movieKeys } from "./movieKeys"; +import { getNowPlayingMovies, getPopularMovies, getTopRatedMovies, getUpcomingMovies } from "../../api/movies"; + +const infiniteOptions = { + initialPageParam: 1, + getNextPageParam: (lastPage: { page: number; total_pages: number }) => + lastPage.page < lastPage.total_pages ? lastPage.page + 1 : undefined, + staleTime: 1000 * 60 * 5, +} as const + + +export const useInfiniteNowPlayingMovies = () => + useInfiniteQuery({ + queryKey: movieKeys.nowPlayingInfinite(), + queryFn: ({ pageParam }) => getNowPlayingMovies(pageParam), + ...infiniteOptions, + }) + +export const useInfinitePopularMovies = () => + useInfiniteQuery({ + queryKey: movieKeys.popularInfinite(), + queryFn: ({ pageParam }) => getPopularMovies(pageParam), + ...infiniteOptions, + }) + +export const useInfiniteTopRatedMovies = () => + useInfiniteQuery({ + queryKey: movieKeys.topRatedInfinite(), + queryFn: ({ pageParam }) => getTopRatedMovies(pageParam), + ...infiniteOptions, + }) + +export const useInfiniteUpcomingMovies = () => + useInfiniteQuery({ + queryKey: movieKeys.upcomingInfinite(), + queryFn: ({ pageParam }) => getUpcomingMovies(pageParam), + ...infiniteOptions, + }) \ No newline at end of file diff --git a/week04/Jerry/src/hooks/queries/useGetMovies.ts b/week04/Jerry/src/hooks/queries/useGetMovies.ts new file mode 100644 index 0000000..ecd4bf9 --- /dev/null +++ b/week04/Jerry/src/hooks/queries/useGetMovies.ts @@ -0,0 +1,36 @@ +import { useQuery } from '@tanstack/react-query'; +import { movieKeys } from './movieKeys'; +import { getNowPlayingMovies, getPopularMovies, getTopRatedMovies, getUpcomingMovies } from '../../api/movies'; + +export const usePopularMovies = (page = 1) => { + return useQuery({ + queryKey: movieKeys.popular(page), + queryFn: () => getPopularMovies(page), + staleTime: 1000 * 60 * 5, + }); +}; + +export const useNowPlayingMovies = (page = 1) => { + return useQuery({ + queryKey: movieKeys.nowPlaying(page), + queryFn: () => getNowPlayingMovies(page), + staleTime: 1000 * 60 * 5, + }); +}; + +export const useTopRatedMovies = (page = 1) => { + return useQuery({ + queryKey: movieKeys.topRated(page), + queryFn: () => getTopRatedMovies(page), + staleTime: 1000 * 60 * 5, + }); +}; + +export const useUpcomingMovies = (page = 1) => { + return useQuery({ + queryKey: movieKeys.upcoming(page), + queryFn: () => getUpcomingMovies(page), + staleTime: 1000 * 60 * 5, + }); +}; + diff --git a/week04/Jerry/src/index.css b/week04/Jerry/src/index.css new file mode 100644 index 0000000..f1d8c73 --- /dev/null +++ b/week04/Jerry/src/index.css @@ -0,0 +1 @@ +@import "tailwindcss"; diff --git a/week04/Jerry/src/main.tsx b/week04/Jerry/src/main.tsx new file mode 100644 index 0000000..39db583 --- /dev/null +++ b/week04/Jerry/src/main.tsx @@ -0,0 +1,13 @@ +import { createRoot } from 'react-dom/client' +import './index.css' +import App from './App.tsx' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { ReactQueryDevtools } from '@tanstack/react-query-devtools' +const queryClient = new QueryClient() + +createRoot(document.getElementById('root')!).render( + + + + +) diff --git a/week04/Jerry/src/pages/ContextApi.tsx b/week04/Jerry/src/pages/ContextApi.tsx new file mode 100644 index 0000000..15b23cd --- /dev/null +++ b/week04/Jerry/src/pages/ContextApi.tsx @@ -0,0 +1,130 @@ +import { SettingsProvider, useSettings } from '../context/SettingsContext' + +function Content() { + const { theme, language, notifications, setTheme, setLanguage, setNotifications, reset } = useSettings() + const isDark = theme === 'dark' + const isKo = language === 'ko' + + const bg = isDark ? 'bg-[#1a1a2e]' : 'bg-gray-100' + const card = isDark ? 'bg-[#2a2a3e]' : 'bg-white' + const inner = isDark ? 'bg-[#1a1a2e]' : 'bg-gray-100' + const text = isDark ? 'text-gray-100' : 'text-gray-800' + const sub = isDark ? 'text-gray-400' : 'text-gray-500' + + const notifs = isKo + ? ['오늘 주문 3건이 배송 준비 중입니다.', '관심 상품 1개가 할인 중입니다.', '신규 쿠폰이 도착했습니다.'] + : ['3 orders are being prepared for shipping.', '1 wishlist item is now on sale.', 'A new coupon has arrived.'] + + return ( +
+ {/* 상단 배지 */} +
+
+

Theme

+

{isDark ? 'Dark' : 'Light'}

+
+
+

Language

+

{isKo ? '한국어' : 'English'}

+
+
+

Notifications

+

{notifications ? 'Enabled' : 'Disabled'}

+
+
+ +
+
+ {/* 설정 패널 */} +
+
+ {isKo ? '설정 패널' : 'Settings Panel'} + +
+
+
+

{isKo ? '테마' : 'Theme'}

+ +
+
+

{isKo ? '언어' : 'Language'}

+ +
+
+

{isKo ? '알림' : 'Notifications'}

+ +
+
+
+ + {/* 미리보기 카드 */} +
+

{isKo ? '미리보기 카드' : 'Preview Card'}

+
+

{isKo ? '현재 테마' : 'Current Theme'}: {isDark ? 'Dark' : 'Light'}

+

+ {isKo ? 'Context에서 가져온 설정을 UI에 반영합니다.' : 'Applying settings from Context to the UI.'} +

+

{isKo ? '알림 상태' : 'Notifications'}: {notifications ? 'ON' : 'OFF'}

+
+
+
+ + {/* 알림 피드 */} +
+
+ {isKo ? '알림 피드' : 'Notification Feed'} + {notifications && ( + {isKo ? '수신 중' : 'Active'} + )} +
+ {notifications ? ( +
+ {notifs.map((n, i) => ( +
{n}
+ ))} +
+ ) : ( +

{isKo ? '알림이 꺼져 있습니다.' : 'Notifications are off.'}

+ )} +

+ {isKo ? '공유 상태' : 'Shared State'}: theme={theme} | language={language} | notifications={String(notifications)} +

+
+
+
+ ) +} + +function ContextApi() { + return ( + + + + ) +} + +export default ContextApi diff --git a/week04/Jerry/src/pages/MainPage.tsx b/week04/Jerry/src/pages/MainPage.tsx new file mode 100644 index 0000000..47d0cd9 --- /dev/null +++ b/week04/Jerry/src/pages/MainPage.tsx @@ -0,0 +1,88 @@ +import { Link } from 'react-router-dom' + +const links = [ + { + to: '/zustand', + label: 'Zustand', + description: '경량 전역 상태 관리', + color: 'from-orange-400 to-pink-500', + emoji: '🐻', + }, + { + to: '/redux', + label: 'Redux', + description: '예측 가능한 상태 컨테이너', + color: 'from-purple-500 to-indigo-600', + emoji: '🔄', + }, + { + to: '/context-api', + label: 'Context API', + description: 'React 내장 상태 공유', + color: 'from-teal-400 to-cyan-500', + emoji: '⚛️', + }, + { + to: '/tanstack-query', + label: 'TanStack Query', + description: '서버 상태 관리 및 캐싱', + color: 'from-green-400 to-blue-500', + emoji: '📊', + } +] + +function MainPage() { + return ( +
+ {/* 배경 장식 */} +
+
+
+
+ +
+ {/* 헤더 */} +
+
+

+ React 중급 과제 +

+

React 3팀

+
+ + {/* 링크 카드들 */} +
+ {links.map(({ to, label, description, color, emoji }) => ( + +
+
+ {emoji} +
+
{label}
+
{description}
+
+ + + +
+
+ + ))} +
+ +

Team 3 · 2025

+
+
+ ) +} + +export default MainPage diff --git a/week04/Jerry/src/pages/Redux.tsx b/week04/Jerry/src/pages/Redux.tsx new file mode 100644 index 0000000..37a62b2 --- /dev/null +++ b/week04/Jerry/src/pages/Redux.tsx @@ -0,0 +1,140 @@ +import { useState } from 'react' +import { Provider, useDispatch, useSelector } from 'react-redux' +import { store } from '../store/store' +import type { RootState, AppDispatch } from '../store/store' +import { addTodo, toggleTodo, deleteTodo, clearDone, resetSample, setFilter } from '../store/todoSlice' +import type { Priority } from '../store/todoSlice' + +const priorityStyle: Record = { + low: 'bg-green-800 text-green-200', + medium: 'bg-yellow-800 text-yellow-200', + high: 'bg-pink-800 text-pink-200', +} + +const priorityLabel: Record = { low: 'low', medium: 'medium', high: 'high' } + +function TodoApp() { + const dispatch = useDispatch() + const { todos, filter } = useSelector((state: RootState) => state.todo) + const [text, setText] = useState('') + const [priority, setPriority] = useState('medium') + + const filtered = todos.filter((t) => + filter === 'all' ? true : filter === 'done' ? t.done : !t.done + ) + + const handleAdd = () => { + if (!text.trim()) return + dispatch(addTodo({ text, priority })) + setText('') + } + + const filterBtns: { label: string; value: typeof filter }[] = [ + { label: '전체', value: 'all' }, + { label: '진행중', value: 'active' }, + { label: '완료', value: 'done' }, + ] + + return ( +
+ {/* 통계 배지 */} +
+ {[ + { label: '전체', count: todos.length, bg: 'bg-[#3b2a1a]' }, + { label: '진행중', count: todos.filter((t) => !t.done).length, bg: 'bg-[#1a3b2a]' }, + { label: '완료', count: todos.filter((t) => t.done).length, bg: 'bg-[#1a1a3b]' }, + ].map(({ label, count, bg }) => ( +
+

{label}

+

{count}

+
+ ))} +
+ + {/* 할 일 추가 */} +
+

할 일 추가

+
+ setText(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && handleAdd()} + placeholder="새 할 일을 입력하세요" + className="bg-[#1a1a2e] border border-gray-600 text-gray-200 p-2 rounded flex-1 text-sm outline-none" + /> + + +
+
+ + {/* 할 일 목록 */} +
+
+

할 일 목록

+
+ {filterBtns.map(({ label, value }) => ( + + ))} +
+
+ +
+ + +
+ +
+ {filtered.map((todo) => ( +
+ dispatch(toggleTodo(todo.id))} + className="cursor-pointer w-4 h-4" + /> + {todo.text} + {priorityLabel[todo.priority]} + + +
+ ))} +
+
+
+ ) +} + +function Redux() { + return ( + + + + ) +} + +export default Redux diff --git a/week04/Jerry/src/pages/TanstackQuery.tsx b/week04/Jerry/src/pages/TanstackQuery.tsx new file mode 100644 index 0000000..643f3bd --- /dev/null +++ b/week04/Jerry/src/pages/TanstackQuery.tsx @@ -0,0 +1,202 @@ +import { useState, useEffect } from 'react' + +import { useInView } from 'react-intersection-observer' +import Logo from '../assets/Logo.png' +import { useInfiniteNowPlayingMovies, useInfinitePopularMovies, useInfiniteTopRatedMovies, useInfiniteUpcomingMovies } from '../hooks/queries/useGetInfiniteMovies' + + +const NAV_TABS = ['현재 상영작', '인기', '높은 평점', '출시 예정'] as const +type NavTab = (typeof NAV_TABS)[number] + +const IMAGE_BASE_URL = 'https://image.tmdb.org/t/p/w300' + +function MovieCardSkeleton() { + return ( +
+
+
+
+
+ ) +} + +function useInfiniteMoviesByTab(tab: NavTab) { + const nowPlaying = useInfiniteNowPlayingMovies() + const popular = useInfinitePopularMovies() + const topRated = useInfiniteTopRatedMovies() + const upcoming = useInfiniteUpcomingMovies() + + switch (tab) { + case '현재 상영작': return nowPlaying + case '인기': return popular + case '높은 평점': return topRated + case '출시 예정': return upcoming + } +} + +function TanstackQuery() { + const [activeTab, setActiveTab] = useState('현재 상영작') + const { data, isLoading, isError, fetchNextPage, hasNextPage, isFetchingNextPage } = useInfiniteMoviesByTab(activeTab) + // ref -> 특정 HTML 요소를 감시할 수 있다. + // inView -> 감시 중인 요소가 화면에 보이면 true + const { ref: observerRef, inView } = useInView({ threshold: 0.5 }) + + useEffect(() => { + if (inView && hasNextPage && !isFetchingNextPage) { + fetchNextPage() + } + }, [inView, hasNextPage, isFetchingNextPage, fetchNextPage]) + + const movies = data?.pages.map((page) => page.results).flat() ?? [] + + return ( +
+
+ CGV + +
+ + + + + +
+
+ +
+ + +
+
+

{activeTab}

+ + {isError && ( +

데이터를 불러오지 못했습니다.

+ )} + +
+ {isLoading + ? Array.from({ length: 10 }).map((_, i) => ) + : movies.map((movie, index) => ( +
+
+ {movie.poster_path ? ( + {movie.title} + ) : ( +
+ No Image +
+ )} + {movie.adult && ( + + 19 + + )} +
+

{movie.title}

+ +
+ ))} + + {isFetchingNextPage && + Array.from({ length: 5 }).map((_, i) => )} +
+ +
+
+ +
+
+
+ ) +} + +export default TanstackQuery diff --git a/week04/Jerry/src/pages/Zustand.tsx b/week04/Jerry/src/pages/Zustand.tsx new file mode 100644 index 0000000..3e92c46 --- /dev/null +++ b/week04/Jerry/src/pages/Zustand.tsx @@ -0,0 +1,77 @@ +import { useFilterStore } from '../store/useFilterStore' + +const CATEGORIES = ['전체', '상의', '하의', '신발', '액세서리'] +const SORTS = ['신상품 우선', '가격 낮은순', '가격 높은순'] +const inputCls = 'bg-[#1a1a2e] border border-gray-600 text-gray-200 p-2 rounded-xl text-sm' + +function Zustand() { + const { category, sort, minPrice, maxPrice, onlyNew, setFilter, reset, getFilteredProducts } = useFilterStore() + const products = getFilteredProducts() + + return ( +
+ +
+
+ 필터 컨트롤 + +
+ +
+
+ + +
+
+ + +
+
+ +
+
+ + setFilter({ minPrice: Number(e.target.value) })} className={inputCls} /> +
+
+ + setFilter({ maxPrice: Number(e.target.value) })} className={inputCls} /> +
+
+ + +
+ +
+
+ 상품 목록 + 총 {products.length}개 +
+ +
+ {products.map(({ id, name, category, price, isNew }) => ( +
+
+ {name} + {isNew && NEW} +
+ 카테고리: {category} + {price.toLocaleString()}원 +
+ ))} +
+
+
+ ) +} + +export default Zustand diff --git a/week04/Jerry/src/store/store.ts b/week04/Jerry/src/store/store.ts new file mode 100644 index 0000000..d929dda --- /dev/null +++ b/week04/Jerry/src/store/store.ts @@ -0,0 +1,9 @@ +import { configureStore } from '@reduxjs/toolkit' +import todoReducer from './todoSlice' + +export const store = configureStore({ + reducer: { todo: todoReducer }, +}) + +export type RootState = ReturnType +export type AppDispatch = typeof store.dispatch diff --git a/week04/Jerry/src/store/todoSlice.ts b/week04/Jerry/src/store/todoSlice.ts new file mode 100644 index 0000000..3b41850 --- /dev/null +++ b/week04/Jerry/src/store/todoSlice.ts @@ -0,0 +1,58 @@ +import { createSlice } from '@reduxjs/toolkit' +import type { PayloadAction } from '@reduxjs/toolkit' + +const SAMPLE: Todo[] = [ + { id: 1, text: '완료와 경우', priority: 'medium', done: true }, + { id: 2, text: '우선순위 낮음', priority: 'low', done: false }, + { id: 3, text: '우선순위 높음', priority: 'high', done: false }, + { id: 4, text: '우선순위 보통', priority: 'medium', done: false }, +] + +export type Priority = 'low' | 'medium' | 'high' + +export interface Todo { + id: number + text: string + priority: Priority + done: boolean +} + +interface TodoState { + todos: Todo[] + filter: 'all' | 'active' | 'done' +} + +const initialState: TodoState = { todos: SAMPLE, filter: 'all' } + +const todoSlice = createSlice({ + name: 'todo', + initialState, + reducers: { + // PayloadAction: action의 typescript 타입 + // payload(text: 할일, priority: 우선순위) + // state: todos 배열 + filter + // action: dispatch할 때 넘긴 정보 (action.payload로 데이터 접근) + addTodo: (state, action: PayloadAction<{ text: string; priority: Priority }>) => { + state.todos.push({ id: Date.now(), text: action.payload.text, priority: action.payload.priority, done: false }) + }, + toggleTodo: (state, action: PayloadAction) => { + const todo = state.todos.find((t) => t.id === action.payload) + if (todo) todo.done = !todo.done + }, + deleteTodo: (state, action: PayloadAction) => { + state.todos = state.todos.filter((t) => t.id !== action.payload) + }, + clearDone: (state) => { + state.todos = state.todos.filter((t) => !t.done) + }, + resetSample: (state) => { + state.todos = SAMPLE + }, + setFilter: (state, action: PayloadAction) => { + state.filter = action.payload + }, + }, +}) + +export const { addTodo, toggleTodo, deleteTodo, clearDone, resetSample, setFilter } = todoSlice.actions +export default todoSlice.reducer diff --git a/week04/Jerry/src/store/useFilterStore.ts b/week04/Jerry/src/store/useFilterStore.ts new file mode 100644 index 0000000..ec32d80 --- /dev/null +++ b/week04/Jerry/src/store/useFilterStore.ts @@ -0,0 +1,65 @@ +import { create } from 'zustand' + +export interface Product { + id: number + name: string + category: string + price: number + isNew: boolean +} + +// 데이터 +const ALL_PRODUCTS: Product[] = [ + { id: 1, name: '베이직 반팔 티', category: '상의', price: 19000, isNew: true }, + { id: 2, name: '러닝 스니커즈', category: '신발', price: 89000, isNew: true }, + { id: 3, name: '스트라이프 셔츠', category: '상의', price: 42000, isNew: true }, + { id: 4, name: '와이드 데님 팬츠', category: '하의', price: 49000, isNew: false }, + { id: 5, name: '미니 크로스백', category: '액세서리', price: 39000, isNew: false }, + { id: 6, name: '코튼 조거 팬츠', category: '하의', price: 36000, isNew: false }, +] + +// 페이지 초깃값 (리셋 시에도 사용) +const INITIAL = { category: '전체', sort: '신상품 우선', minPrice: 0, maxPrice: 100000, onlyNew: false } + +interface FilterState { + // 상태 + category: string + sort: string + minPrice: number + maxPrice: number + onlyNew: boolean + + // 액션 + setFilter: (patch: Partial) => void // initial type을 가져오되, partial로 모든 키를 optional 하게 설정 + reset: () => void + getFilteredProducts: () => Product[] +} + +export const useFilterStore = create((set, get) => ({ + ...INITIAL, + + setFilter: (patch) => set(patch), + reset: () => set(INITIAL), + + getFilteredProducts: () => { + // 현재 상태 읽기(get) + const { category, sort, minPrice, maxPrice, onlyNew } = get() + // Record(key, value) 형태 반환: string -> (a:product, b:product)로 number 반환(정렬) + const sorters: Record number> = { + // array.sort() 이용 → sort((a, b) => ) 음수인 경우 a가 앞으로, 양수인 경우 b가 앞으로 + '신상품 우선': (a, b) => Number(b.isNew) - Number(a.isNew), // isNew: 1/0 + '가격 낮은순': (a, b) => a.price - b.price, + '가격 높은순': (a, b) => b.price - a.price, + } + return ALL_PRODUCTS + .filter((p) => + // 전체이거나 카테고리가 일치하거나 + (category === '전체' || p.category === category) && + // 가격 범위 안에 있고 + p.price >= minPrice && p.price <= maxPrice && + // 신상품 체크인지 아닌지 + (!onlyNew || p.isNew) + ) + .sort(sorters[sort]) + }, +})) diff --git a/week04/Jerry/src/types/movie.ts b/week04/Jerry/src/types/movie.ts new file mode 100644 index 0000000..a3e7d15 --- /dev/null +++ b/week04/Jerry/src/types/movie.ts @@ -0,0 +1,19 @@ +export interface Movie { + id: number; + title: string; + overview: string; + poster_path: string | null; + release_date: string; + vote_average: number; + popularity: number; + adult: boolean; + original_language: string; + original_title: string; +} + +export interface MovieListResponse { + page: number; + results: Movie[]; + total_pages: number; + total_results: number; +} \ No newline at end of file