diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 12f62bb..a1fbda7 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -4,6 +4,7 @@ import { BrowserRouter, Route, Routes } from 'react-router-dom'; import Layout from './layout'; import ClubDetail from './pages/ClubDetail'; import Home from './pages/Home'; +import RegisterClub from './pages/RegisterClub'; import UniversityClubList from './pages/UniversityClubList'; const SentryRoutes = Sentry.withSentryReactRouterV7Routing(Routes); @@ -15,6 +16,7 @@ function App() { }> } /> } /> + } /> } /> diff --git a/apps/web/src/apis/clubDetail/entity.ts b/apps/web/src/apis/clubDetail/entity.ts index fc61399..9adc1ac 100644 --- a/apps/web/src/apis/clubDetail/entity.ts +++ b/apps/web/src/apis/clubDetail/entity.ts @@ -2,10 +2,6 @@ import type { ClubCategory } from '@/apis/common/club'; import type { UniversitySummary } from '../universityClub/entity'; -export interface ClubDetailUniversitySummary extends UniversitySummary { - clubCount: number; -} - export interface ClubDetailResponse { id: number; name: string; @@ -16,5 +12,5 @@ export interface ClubDetailResponse { description: string; introduce: string; location: string; - university: ClubDetailUniversitySummary; + university: UniversitySummary; } diff --git a/apps/web/src/apis/universityClub/entity.ts b/apps/web/src/apis/universityClub/entity.ts index b625af1..d375fb1 100644 --- a/apps/web/src/apis/universityClub/entity.ts +++ b/apps/web/src/apis/universityClub/entity.ts @@ -15,6 +15,7 @@ export interface UniversitySummary { region: Region; regionName: string; imageUrl: string; + clubCount: number; } export interface ClubCategorySummary { diff --git a/apps/web/src/assets/edit-club-detail.png b/apps/web/src/assets/edit-club-detail.png new file mode 100644 index 0000000..2518758 Binary files /dev/null and b/apps/web/src/assets/edit-club-detail.png differ diff --git a/apps/web/src/assets/new-club.png b/apps/web/src/assets/new-club.png new file mode 100644 index 0000000..92b2d83 Binary files /dev/null and b/apps/web/src/assets/new-club.png differ diff --git a/apps/web/src/assets/register-club.png b/apps/web/src/assets/register-club.png new file mode 100644 index 0000000..4dff290 Binary files /dev/null and b/apps/web/src/assets/register-club.png differ diff --git a/apps/web/src/components/RecentClubList/index.tsx b/apps/web/src/components/RecentClubList/index.tsx index f780a1a..8574d73 100644 --- a/apps/web/src/components/RecentClubList/index.tsx +++ b/apps/web/src/components/RecentClubList/index.tsx @@ -10,9 +10,17 @@ interface RecentClubListProps { emptyClassName?: string; } +interface RecentClubListByIdsProps extends RecentClubListProps { + recentClubIds: number[]; +} + function RecentClubList({ className, emptyClassName }: RecentClubListProps) { const recentClubIds = useRecentClubIds(); + return ; +} + +export function RecentClubListByIds({ className, emptyClassName, recentClubIds }: RecentClubListByIdsProps) { if (recentClubIds.length === 0) { return ; } diff --git a/apps/web/src/components/UniversityClubSidebar/index.tsx b/apps/web/src/components/UniversityClubSidebar/index.tsx index 28e568f..0cf57bc 100644 --- a/apps/web/src/components/UniversityClubSidebar/index.tsx +++ b/apps/web/src/components/UniversityClubSidebar/index.tsx @@ -24,7 +24,7 @@ function UniversityClubSidebar({ university, clubCount }: UniversityClubSidebarP -
+

최근에 본 동아리

diff --git a/apps/web/src/layout/Header/index.tsx b/apps/web/src/layout/Header/index.tsx index 0a0611e..d1f1711 100644 --- a/apps/web/src/layout/Header/index.tsx +++ b/apps/web/src/layout/Header/index.tsx @@ -1,9 +1,11 @@ +import { useNavigate } from 'react-router-dom'; import Logo from '@/assets/image/Logo.png'; function Header() { + const navigate = useNavigate(); return (
-
+
로고 @@ -13,6 +15,9 @@ function Header() { diff --git a/apps/web/src/pages/ClubDetail/index.tsx b/apps/web/src/pages/ClubDetail/index.tsx index 6ab1f55..e5873a1 100644 --- a/apps/web/src/pages/ClubDetail/index.tsx +++ b/apps/web/src/pages/ClubDetail/index.tsx @@ -10,6 +10,7 @@ import AddPhotoIcon from '@/assets/svg/add-photo-icon.svg'; import Breadcrumb from '@/components/Breadcrumb'; import UniversityClubSidebar from '@/components/UniversityClubSidebar'; import { CATEGORY_TEXT_COLORS } from '@/constants/club'; +import useResetScroll from '@/utils/hooks/useResetScroll'; import { saveRecentClubId } from '@/utils/recentClubStorage'; const INTRODUCE_MEDIA_ITEMS = [ @@ -55,6 +56,9 @@ function NoneIntroduce() { export default function ClubDetail() { const { clubId } = useParams(); + + useResetScroll(clubId); + const { data: clubDetail } = useSuspenseQuery(clubDetailQueries.detail(Number(clubId))); useEffect(() => { diff --git a/apps/web/src/pages/Home/index.tsx b/apps/web/src/pages/Home/index.tsx index fc98c8f..b49cbca 100644 --- a/apps/web/src/pages/Home/index.tsx +++ b/apps/web/src/pages/Home/index.tsx @@ -1,29 +1,37 @@ -import { useState, type ChangeEvent } from 'react'; +import { useEffect, useRef, 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 { Link, useSearchParams } from 'react-router-dom'; import type { Region, HomeRequestParams, University } from '@/apis/home/entity'; import { homeQueries } from '@/apis/home/queries'; import heroCatBook from '@/assets/hero-cat-book.png'; import SearchIcon from '@/assets/svg/search-icon.svg'; -import RecentClubList from '@/components/RecentClubList'; +import { RecentClubListByIds } from '@/components/RecentClubList'; +import { useRecentClubIds } from '@/utils/recentClubStorage'; const REGION_OPTIONS: { label: string; value?: Region }[] = [ { label: '전체' }, { label: '서울', value: 'SEOUL' }, { label: '경기도', value: 'GYEONGGI' }, + { label: '강원도', value: 'GANGWON' }, { label: '충청도', value: 'CHUNGCHEONG' }, - { label: '전라도', value: 'JEOLLA' }, { label: '경상도', value: 'GYEONGSANG' }, - { label: '강원도', value: 'GANGWON' }, + { label: '전라도', value: 'JEOLLA' }, { label: '제주도', value: 'JEJU' }, ]; +const HOME_UNIVERSITY_SECTION = 'universities'; +const REGION_PARAM = 'region'; +const SECTION_PARAM = 'section'; + function Home() { - const [selectedRegion, setSelectedRegion] = useState(); + const [searchParams, setSearchParams] = useSearchParams(); const [searchKeyword, setSearchKeyword] = useState(''); const [searchQuery, setSearchQuery] = useState(''); + const universityListRef = useRef(null); + const selectedRegion = getRegionParam(searchParams.get(REGION_PARAM)); + const targetSection = searchParams.get(SECTION_PARAM); const updateSearchQuery = useDebouncedCallback((value: string) => { setSearchQuery(value.trim()); @@ -37,7 +45,9 @@ function Home() { const { data: homeData } = useSuspenseQuery(homeQueries.detail(homeParams)); const universities = homeData.universities ?? []; const totalUniversityCount = homeData.totalUniversityCount; + const recentClubIds = useRecentClubIds(); const isSearching = searchKeyword.trim().length > 0 || searchQuery.length > 0; + const hasRecentClubs = recentClubIds.length > 0; const handleSearchKeywordChange = (event: ChangeEvent) => { const value = event.target.value; @@ -45,6 +55,28 @@ function Home() { updateSearchQuery(value); }; + const handleRegionChange = (region?: Region) => { + setSearchParams((prev) => { + const params = new URLSearchParams(prev); + + if (region) params.set(REGION_PARAM, region); + else params.delete(REGION_PARAM); + + params.delete(SECTION_PARAM); + return params; + }); + }; + + useEffect(() => { + if (targetSection !== HOME_UNIVERSITY_SECTION) return; + + const animationFrameId = requestAnimationFrame(() => { + universityListRef.current?.scrollIntoView({ block: 'start' }); + }); + + return () => cancelAnimationFrame(animationFrameId); + }, [selectedRegion, targetSection]); + return (
@@ -87,21 +119,27 @@ function Home() {
-
-
- - -
-
+ {hasRecentClubs ? ( +
+
+ + +
+
+ ) : null} -
+
@@ -110,15 +148,15 @@ function Home() { return ( @@ -188,4 +226,8 @@ function UniversityCard({ university }: { university: University }) { ); } +function getRegionParam(value: string | null): Region | undefined { + return REGION_OPTIONS.some((region) => region.value === value) ? (value as Region) : undefined; +} + export default Home; diff --git a/apps/web/src/pages/RegisterClub/index.tsx b/apps/web/src/pages/RegisterClub/index.tsx new file mode 100644 index 0000000..48b5051 --- /dev/null +++ b/apps/web/src/pages/RegisterClub/index.tsx @@ -0,0 +1,56 @@ +import EditClub from '@/assets/edit-club-detail.png'; +import NewClub from '@/assets/new-club.png'; +import Register from '@/assets/register-club.png'; +export default function RegisterClub() { + const registerClubCards = [ + { + image: EditClub, + imageAlt: '동아리 정보 수정', + title: '동아리 정보 수정', + description: '이미 KONECT에 등록된 동아리의 소개, 사진, 상세정보를 추가하거나 수정할 수 있어요', + target: '대상 : 동아리 회장, 임원진', + }, + { + image: NewClub, + imageAlt: '신규 동아리 등록', + title: '신규 동아리 등록', + description: '아직 KONECT에 등록되지 않은 동아리의 기본 정보와 소개 정보를 제출할 수 있어요.', + target: '대상 : 미등록된 동아리의 관계자', + }, + { + image: Register, + imageAlt: '학교 동아리 목록 등록', + title: '학교 동아리 목록 등록', + description: '총동 / 학생회 담당자가 학교 단위의 동아리 목록과 기본 정보를 한 번에 전달할 수 있어요.', + target: '대상 : 총동아리 연합회 / 학생회 담당자', + }, + ]; + return ( +
+
+
+
+ + 작성자 유형에 따라 필요한 항목이 다릅니다. 현재 상황에 맞는 항목을 선택해주세요. + +
+
+ {registerClubCards.map((card) => ( +
+ {card.imageAlt} +
+

{card.title}

+ {card.description} + {card.target} +
+
+ ))} +
+
+ ); +} diff --git a/apps/web/src/pages/UniversityClubList/index.tsx b/apps/web/src/pages/UniversityClubList/index.tsx index cb12ca5..5e5cc95 100644 --- a/apps/web/src/pages/UniversityClubList/index.tsx +++ b/apps/web/src/pages/UniversityClubList/index.tsx @@ -5,14 +5,17 @@ import { useSuspenseInfiniteQuery } from '@tanstack/react-query'; import { Link, Navigate, useParams, useSearchParams } from 'react-router-dom'; import { isClubCategory, type ClubCategory } from '@/apis/common/club'; +import type { Region } from '@/apis/home/entity'; import type { UniversityClub, UniversityClubListRequestParams } from '@/apis/universityClub/entity'; import { universityClubQueries } from '@/apis/universityClub/queries'; import SearchIcon from '@/assets/svg/search-icon.svg'; import Breadcrumb from '@/components/Breadcrumb'; import UniversityClubSidebar from '@/components/UniversityClubSidebar'; import { CATEGORY_TEXT_COLORS } from '@/constants/club'; +import useResetScroll from '@/utils/hooks/useResetScroll'; const PAGE_LIMIT = 12; +const HOME_UNIVERSITY_SECTION = 'universities'; function UniversityClubList() { const { universityId } = useParams(); @@ -32,6 +35,8 @@ function UniversityClubListContent({ universityId }: { universityId: number }) { const [searchKeyword, setSearchKeyword] = useState(query); const observerRef = useRef(null); + useResetScroll(universityId); + const updateSearchQuery = useDebouncedCallback((value: string) => { updateListSearchParams(setSearchParams, { query: value.trim() }); }); @@ -49,7 +54,7 @@ function UniversityClubListContent({ universityId }: { universityId: number }) { const { data, fetchNextPage, hasNextPage, isFetchingNextPage } = useSuspenseInfiniteQuery( universityClubQueries.infiniteList(universityId, requestParams) ); - const [{ university, totalCount, categories }] = data.pages; + const [{ university, categories }] = data.pages; const clubs = data.pages.flatMap((pageData) => pageData.clubs); const handleSearchKeywordChange = (event: ChangeEvent) => { @@ -88,10 +93,16 @@ function UniversityClubListContent({ universityId }: { universityId: number }) { return (
- +
- +
@@ -106,29 +117,27 @@ function UniversityClubListContent({ universityId }: { universityId: number }) { -
-
+
+ handleCategoryChange()} + /> + {categories.map((category) => ( handleCategoryChange()} + key={category.category} + label={category.categoryName} + count={category.count} + isSelected={category.category === selectedCategory} + onClick={() => handleCategoryChange(category.category)} /> - {categories.map((category) => ( - handleCategoryChange(category.category)} - /> - ))} -
+ ))}
-

+

선택한 대학의 동아리를 확인해보세요. 관심 있는 동아리의 소개를 살펴볼 수 있어요.

@@ -224,6 +233,16 @@ function getCategoryParam(value: string | null): ClubCategory | undefined { return undefined; } +function getHomeUniversitySectionPath(region: Region) { + const params = new URLSearchParams({ section: HOME_UNIVERSITY_SECTION }); + + if (region !== 'UNKNOWN') { + params.set('region', region); + } + + return `/?${params.toString()}`; +} + function updateListSearchParams( setSearchParams: ReturnType[1], next: { query?: string; category?: ClubCategory } diff --git a/apps/web/src/utils/hooks/useResetScroll.ts b/apps/web/src/utils/hooks/useResetScroll.ts new file mode 100644 index 0000000..304d1b3 --- /dev/null +++ b/apps/web/src/utils/hooks/useResetScroll.ts @@ -0,0 +1,9 @@ +import { useLayoutEffect } from 'react'; + +export function useResetScroll(resetKey?: unknown) { + useLayoutEffect(() => { + window.scrollTo({ top: 0, left: 0 }); + }, [resetKey]); +} + +export default useResetScroll;