diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index aabf2ff..7511218 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -1,7 +1,9 @@ import * as Sentry from '@sentry/react'; import { BrowserRouter, Route, Routes } from 'react-router-dom'; + import Layout from './layout'; import Home from './pages/Home'; +import UniversityClubList from './pages/UniversityClubList'; const SentryRoutes = Sentry.withSentryReactRouterV7Routing(Routes); @@ -11,6 +13,7 @@ function App() { }> } /> + } /> diff --git a/apps/web/src/apis/universityClub/entity.ts b/apps/web/src/apis/universityClub/entity.ts new file mode 100644 index 0000000..3231dcc --- /dev/null +++ b/apps/web/src/apis/universityClub/entity.ts @@ -0,0 +1,44 @@ +import type { Region } from '@/apis/home/entity'; + +export type ClubCategory = 'ACADEMIC' | 'SPORTS' | 'HOBBY' | 'RELIGION' | 'PERFORMANCE' | 'JUNIOR'; + +export interface UniversityClubListRequestParams { + page?: number; + limit?: number; + query?: string; + category?: ClubCategory; +} + +export interface UniversitySummary { + id: number; + name: string; + campusName: string; + region: Region; + regionName: string; + imageUrl: string; +} + +export interface ClubCategorySummary { + category: ClubCategory; + categoryName: string; + count: number; +} + +export interface UniversityClub { + id: number; + name: string; + imageUrl: string; + category: ClubCategory; + categoryName: string; + description: string; + memberCount: number; +} + +export interface UniversityClubListResponse { + university: UniversitySummary; + totalCount: number; + totalPage: number; + currentPage: number; + categories: ClubCategorySummary[]; + clubs: UniversityClub[]; +} diff --git a/apps/web/src/apis/universityClub/index.ts b/apps/web/src/apis/universityClub/index.ts new file mode 100644 index 0000000..fcdbaa8 --- /dev/null +++ b/apps/web/src/apis/universityClub/index.ts @@ -0,0 +1,10 @@ +import { apiClient } from '../client'; +import type { UniversityClubListRequestParams, UniversityClubListResponse } from './entity'; + +export const getUniversityClubs = async (universityId: number, params?: UniversityClubListRequestParams) => { + const response = await apiClient.get( + `konect/universities/${universityId}/clubs`, + { params } + ); + return response; +}; diff --git a/apps/web/src/apis/universityClub/queries.ts b/apps/web/src/apis/universityClub/queries.ts new file mode 100644 index 0000000..3e95b32 --- /dev/null +++ b/apps/web/src/apis/universityClub/queries.ts @@ -0,0 +1,52 @@ +import { infiniteQueryOptions, queryOptions } from '@tanstack/react-query'; + +import type { UniversityClubListRequestParams, UniversityClubListResponse } from './entity'; +import { getUniversityClubs } from '.'; + +type UniversityClubInfiniteListParams = Omit; + +export const universityClubQueryKeys = { + all: ['universityClub'] as const, + list: (universityId: number, params: UniversityClubListRequestParams) => + [ + ...universityClubQueryKeys.all, + 'list', + universityId, + params.page ?? 1, + params.limit ?? 12, + params.query ?? '', + params.category ?? '', + ] as const, + infinite: { + all: () => [...universityClubQueryKeys.all, 'infinite'] as const, + list: (universityId: number, params: UniversityClubInfiniteListParams) => + [ + ...universityClubQueryKeys.infinite.all(), + universityId, + params.limit ?? 12, + params.query ?? '', + params.category ?? '', + ] as const, + }, +}; + +export const universityClubQueries = { + list: (universityId: number, params: UniversityClubListRequestParams) => + queryOptions({ + queryKey: universityClubQueryKeys.list(universityId, params), + queryFn: () => getUniversityClubs(universityId, params), + }), + infiniteList: (universityId: number, params: UniversityClubInfiniteListParams) => + infiniteQueryOptions({ + queryKey: universityClubQueryKeys.infinite.list(universityId, params), + queryFn: ({ pageParam }) => getUniversityClubs(universityId, { ...params, page: pageParam }), + initialPageParam: 1, + getNextPageParam: (lastPage: UniversityClubListResponse) => { + if (lastPage.currentPage < lastPage.totalPage) { + return lastPage.currentPage + 1; + } + + return undefined; + }, + }), +}; diff --git a/apps/web/src/pages/Home/index.tsx b/apps/web/src/pages/Home/index.tsx index e8a6257..4e90f7f 100644 --- a/apps/web/src/pages/Home/index.tsx +++ b/apps/web/src/pages/Home/index.tsx @@ -1,6 +1,8 @@ import { useState, type ChangeEvent } from 'react'; import { useDebouncedCallback } from '@konect/utils/use-debounced-callback'; import { useSuspenseQuery } from '@tanstack/react-query'; +import { Link } from 'react-router-dom'; + import type { Region, HomeRequestParams, University } from '@/apis/home/entity'; import { homeQueries } from '@/apis/home/queries'; import clubBadgeBlue from '@/assets/club-badge-blue.png'; @@ -75,6 +77,7 @@ function Home() { const { data: homeData } = useSuspenseQuery(homeQueries.detail(homeParams)); const universities = homeData.universities ?? []; const totalUniversityCount = homeData.totalUniversityCount; + const isSearching = searchKeyword.trim().length > 0 || searchQuery.length > 0; const handleSearchKeywordChange = (event: ChangeEvent) => { const value = event.target.value; @@ -124,14 +127,20 @@ function Home() { - - - - {recentClubs.map((club) => ( - - ))} - - + + + + + {recentClubs.map((club) => ( + + ))} + + + @@ -187,7 +196,7 @@ function SectionTitle({ title, description }: { title: string; description: stri function RecentClubCard({ club }: { club: RecentClub }) { return ( @@ -228,14 +237,14 @@ function UniversityCard({ university }: { university: University }) { const universityLabel = university.campusName ? `${university.name} ${university.campusName}` : university.name; return ( - {universityLabel} {university.clubCount}개 동아리 - + ); } diff --git a/apps/web/src/pages/UniversityClubList/index.tsx b/apps/web/src/pages/UniversityClubList/index.tsx new file mode 100644 index 0000000..0f825dc --- /dev/null +++ b/apps/web/src/pages/UniversityClubList/index.tsx @@ -0,0 +1,352 @@ +import { useEffect, useMemo, useRef, useState, type ChangeEvent } from 'react'; +import { cn } from '@konect/utils/cn'; +import { useDebouncedCallback } from '@konect/utils/use-debounced-callback'; +import { useSuspenseInfiniteQuery } from '@tanstack/react-query'; +import { Link, Navigate, useParams, useSearchParams } from 'react-router-dom'; + +import type { ClubCategory, UniversityClub, UniversityClubListRequestParams } from '@/apis/universityClub/entity'; +import { universityClubQueries } from '@/apis/universityClub/queries'; +import SearchIcon from '@/assets/svg/search-icon.svg'; + +const PAGE_LIMIT = 12; + +const CATEGORY_TEXT_COLORS: Record = { + ACADEMIC: 'text-primary-500', + SPORTS: 'text-info-600', + HOBBY: 'text-danger-600', + RELIGION: 'text-warning-700', + PERFORMANCE: 'text-[#cd3bf6]', + JUNIOR: 'text-success-700', +}; + +function UniversityClubList() { + const { universityId } = useParams(); + const parsedUniversityId = Number(universityId); + + if (!Number.isInteger(parsedUniversityId) || parsedUniversityId <= 0) { + return ; + } + + return ; +} + +function UniversityClubListContent({ universityId }: { universityId: number }) { + const [searchParams, setSearchParams] = useSearchParams(); + const selectedCategory = getCategoryParam(searchParams.get('category')); + const query = searchParams.get('query')?.trim() ?? ''; + const [searchKeyword, setSearchKeyword] = useState(query); + const observerRef = useRef(null); + + const updateSearchQuery = useDebouncedCallback((value: string) => { + updateListSearchParams(setSearchParams, { query: value.trim() }); + }); + + const requestParams = useMemo( + () => + ({ + limit: PAGE_LIMIT, + ...(query ? { query } : {}), + ...(selectedCategory ? { category: selectedCategory } : {}), + }) satisfies UniversityClubListRequestParams, + [query, selectedCategory] + ); + + const { data, fetchNextPage, hasNextPage, isFetchingNextPage } = useSuspenseInfiniteQuery( + universityClubQueries.infiniteList(universityId, requestParams) + ); + const firstPage = data.pages[0]; + const { university, totalCount, categories } = firstPage; + const clubs = data.pages.flatMap((pageData) => pageData.clubs); + const universityLabel = university.campusName ? `${university.name} ${university.campusName}` : university.name; + const categoryTotalCount = categories.reduce((sum, category) => sum + category.count, 0); + const allClubCount = categoryTotalCount || totalCount; + const recentClubs = clubs.slice(0, 4); + + useEffect(() => { + setSearchKeyword(query); + }, [query]); + + useEffect(() => { + const target = observerRef.current; + + if (!target || !hasNextPage) return; + + const observer = new IntersectionObserver( + ([entry]) => { + if (entry?.isIntersecting && hasNextPage && !isFetchingNextPage) { + fetchNextPage(); + } + }, + { rootMargin: '240px 0px' } + ); + + observer.observe(target); + + return () => observer.disconnect(); + }, [fetchNextPage, hasNextPage, isFetchingNextPage]); + + const handleSearchKeywordChange = (event: ChangeEvent) => { + const value = event.target.value; + setSearchKeyword(value); + updateSearchQuery(value); + }; + + const handleCategoryChange = (category?: ClubCategory) => { + updateListSearchParams(setSearchParams, { category }); + }; + + return ( + + + + + 홈 + + + › + + {university.regionName} + + › + + {university.name} + + + + + + + + + 동아리명 검색 + + + + + + + handleCategoryChange()} + /> + {categories.map((category) => ( + handleCategoryChange(category.category)} + /> + ))} + + + + + + + 선택한 대학의 동아리를 확인해보세요. 관심 있는 동아리의 소개를 살펴볼 수 있어요. + + + {clubs.length > 0 ? ( + + {clubs.map((club) => ( + + ))} + + ) : ( + + )} + + {hasNextPage && ( + + {isFetchingNextPage ? '동아리를 불러오는 중이에요.' : null} + + )} + + + + + + ); +} + +function RecentClubCard({ club }: { club: UniversityClub }) { + return ( + + + + + ); +} + +function ClubCard({ club }: { club: UniversityClub }) { + return ( + + + + + ); +} + +function ClubImage({ className, imageUrl, name }: { className: string; imageUrl?: string; name: string }) { + if (imageUrl) { + return ; + } + + return ( + + {name} + + ); +} + +function ClubMeta({ + club, + titleClassName, + categoryClassName, + descriptionClassName, +}: { + club: UniversityClub; + titleClassName: string; + categoryClassName: string; + descriptionClassName: string; +}) { + return ( + + {club.name} + + + {club.categoryName} + + + + {club.description || `${club.memberCount}명`} + + + + ); +} + +function CategoryFilterButton({ + label, + count, + isSelected, + onClick, +}: { + label: string; + count: number; + isSelected: boolean; + onClick: () => void; +}) { + return ( + + {label} + {count} + + ); +} + +function ClubListMessage({ message }: { message: string }) { + return ( + + {message} + + ); +} + +function getCategoryParam(value: string | null): ClubCategory | undefined { + if ( + value === 'ACADEMIC' || + value === 'SPORTS' || + value === 'HOBBY' || + value === 'RELIGION' || + value === 'PERFORMANCE' || + value === 'JUNIOR' + ) { + return value; + } + + return undefined; +} + +function updateListSearchParams( + setSearchParams: ReturnType[1], + next: { query?: string; category?: ClubCategory } +) { + setSearchParams((prev) => { + const params = new URLSearchParams(prev); + + if ('query' in next) { + if (next.query) params.set('query', next.query); + else params.delete('query'); + } + + if ('category' in next) { + if (next.category) params.set('category', next.category); + else params.delete('category'); + } + return params; + }); +} + +export default UniversityClubList;
+ 선택한 대학의 동아리를 확인해보세요. 관심 있는 동아리의 소개를 살펴볼 수 있어요. +