diff --git a/apps/web/public/images/unvis/incheon.png b/apps/web/public/images/unvis/incheon.png new file mode 100644 index 00000000..b849061a Binary files /dev/null and b/apps/web/public/images/unvis/incheon.png differ diff --git a/apps/web/public/images/unvis/inha.png b/apps/web/public/images/unvis/inha.png new file mode 100644 index 00000000..dbb99bba Binary files /dev/null and b/apps/web/public/images/unvis/inha.png differ diff --git a/apps/web/public/images/unvis/sungsin.jpg b/apps/web/public/images/unvis/sungsin.jpg new file mode 100644 index 00000000..a95d01b3 Binary files /dev/null and b/apps/web/public/images/unvis/sungsin.jpg differ diff --git a/apps/web/src/apis/universities/api.ts b/apps/web/src/apis/universities/api.ts index cde9f708..56b2bb19 100644 --- a/apps/web/src/apis/universities/api.ts +++ b/apps/web/src/apis/universities/api.ts @@ -1,3 +1,4 @@ +import type { HomeUniversityName } from "@/types/university"; import { axiosInstance, publicAxiosInstance } from "@/utils/axiosInstance"; export interface RecommendedUniversitiesResponseRecommendedUniversitiesItem { @@ -95,6 +96,7 @@ export interface SearchTextResponseUnivApplyInfoPreviewsItem { backgroundImageUrl: string; studentCapacity: number; languageRequirements: SearchTextResponseUnivApplyInfoPreviewsItemLanguageRequirementsItem[]; + homeUniversityName?: HomeUniversityName; } export interface SearchTextResponseUnivApplyInfoPreviewsItemLanguageRequirementsItem { diff --git a/apps/web/src/apis/universities/getSearchFilter.ts b/apps/web/src/apis/universities/getSearchFilter.ts index 0b520318..54499e93 100644 --- a/apps/web/src/apis/universities/getSearchFilter.ts +++ b/apps/web/src/apis/universities/getSearchFilter.ts @@ -1,6 +1,7 @@ import { useQuery } from "@tanstack/react-query"; import type { AxiosError } from "axios"; -import type { CountryCode, LanguageTestType, ListUniversity } from "@/types/university"; +import { useMemo } from "react"; +import type { CountryCode, HomeUniversityName, LanguageTestType, ListUniversity } from "@/types/university"; import { QueryKeys } from "../queryKeys"; import { type SearchFilterResponse, universitiesApi } from "./api"; @@ -10,11 +11,20 @@ export interface UniversitySearchFilterParams { countryCode?: CountryCode[]; } +// API 응답에 homeUniversityName이 포함된 타입 +interface ListUniversityWithHome extends ListUniversity { + homeUniversityName?: HomeUniversityName; +} + /** * @description 필터로 대학 검색을 위한 useQuery 커스텀 훅 * @param filters - 검색 필터 파라미터 + * @param homeUniversityName - 홈 대학교 이름 (선택적 필터) */ -const useGetUniversitySearchByFilter = (filters: UniversitySearchFilterParams) => { +const useGetUniversitySearchByFilter = ( + filters: UniversitySearchFilterParams, + homeUniversityName?: HomeUniversityName, +) => { // 필터 파라미터 구성 const buildParams = () => { const params: Record = {}; @@ -30,15 +40,26 @@ const useGetUniversitySearchByFilter = (filters: UniversitySearchFilterParams) = return params; }; - return useQuery({ + const query = useQuery({ queryKey: [QueryKeys.universities.searchFilter, filters], queryFn: () => universitiesApi.getSearchFilter({ params: buildParams() }), enabled: Object.values(filters).some((value) => { if (Array.isArray(value)) return value.length > 0; return value !== undefined && value !== ""; }), - select: (data) => data.univApplyInfoPreviews as unknown as ListUniversity[], + select: (data) => data.univApplyInfoPreviews as unknown as ListUniversityWithHome[], }); + + // homeUniversityName으로 필터링 + const filteredData = useMemo(() => { + if (!query.data || !homeUniversityName) return query.data; + return query.data.filter((university) => university.homeUniversityName === homeUniversityName); + }, [query.data, homeUniversityName]); + + return { + ...query, + data: filteredData, + }; }; export default useGetUniversitySearchByFilter; diff --git a/apps/web/src/apis/universities/getSearchText.ts b/apps/web/src/apis/universities/getSearchText.ts index ad2bab9d..1ca20e45 100644 --- a/apps/web/src/apis/universities/getSearchText.ts +++ b/apps/web/src/apis/universities/getSearchText.ts @@ -2,44 +2,58 @@ import { useQuery } from "@tanstack/react-query"; import type { AxiosError } from "axios"; import { useMemo } from "react"; -import type { ListUniversity } from "@/types/university"; +import type { HomeUniversityName, ListUniversity } from "@/types/university"; import { QueryKeys } from "../queryKeys"; import { type SearchTextResponse, universitiesApi } from "./api"; +// API 응답에 homeUniversityName이 포함된 타입 +interface ListUniversityWithHome extends ListUniversity { + homeUniversityName?: HomeUniversityName; +} + /** * @description 대학 검색을 위한 useQuery 커스텀 훅 * 모든 대학 데이터를 한 번만 가져와 캐싱하고, 검색어에 따라 클라이언트에서 필터링합니다. * @param searchValue - 검색어 + * @param homeUniversityName - 홈 대학교 이름 (선택적 필터) */ -const useUniversitySearch = (searchValue: string) => { +const useUniversitySearch = (searchValue: string, homeUniversityName?: HomeUniversityName) => { // 1. 모든 대학 데이터를 한 번만 가져와 'Infinity' 캐시로 저장합니다. const { data: allUniversities, isLoading, isError, error, - } = useQuery({ + } = useQuery({ queryKey: [QueryKeys.universities.searchText], queryFn: () => universitiesApi.getSearchText({ value: "" }), staleTime: Infinity, gcTime: Infinity, - select: (data) => data.univApplyInfoPreviews as unknown as ListUniversity[], + select: (data) => data.univApplyInfoPreviews as unknown as ListUniversityWithHome[], }); - // 2. 검색어가 변경될 때만 캐시된 데이터를 필터링합니다. + // 2. 검색어와 homeUniversityName에 따라 필터링합니다. const filteredUniversities = useMemo(() => { const normalizedSearchValue = searchValue.trim().toLowerCase(); - if (!normalizedSearchValue) { - return allUniversities; - } - if (!allUniversities) { return []; } - return allUniversities.filter((university) => university.koreanName.toLowerCase().includes(normalizedSearchValue)); - }, [allUniversities, searchValue]); + let filtered = allUniversities; + + // homeUniversityName 필터링 + if (homeUniversityName) { + filtered = filtered.filter((university) => university.homeUniversityName === homeUniversityName); + } + + // 검색어 필터링 + if (normalizedSearchValue) { + filtered = filtered.filter((university) => university.koreanName.toLowerCase().includes(normalizedSearchValue)); + } + + return filtered; + }, [allUniversities, searchValue, homeUniversityName]); return { data: filteredUniversities, diff --git a/apps/web/src/app/university/list/[homeUniversityName]/SearchResultsContent.tsx b/apps/web/src/app/university/list/[homeUniversityName]/SearchResultsContent.tsx new file mode 100644 index 00000000..a33355a7 --- /dev/null +++ b/apps/web/src/app/university/list/[homeUniversityName]/SearchResultsContent.tsx @@ -0,0 +1,113 @@ +"use client"; + +import { useSearchParams } from "next/navigation"; +import React, { Suspense, useMemo, useState } from "react"; +// 필요한 타입과 훅 import +import { + type UniversitySearchFilterParams, + useGetUniversitySearchByFilter, + useUniversitySearch, +} from "@/apis/universities"; +import CloudSpinnerPage from "@/components/ui/CloudSpinnerPage"; +import FloatingUpBtn from "@/components/ui/FloatingUpBtn"; +import UniversityCards from "@/components/university/UniversityCards"; +import { type CountryCode, type HomeUniversityName, type LanguageTestType, RegionEnumExtend } from "@/types/university"; +import RegionFilter from "../../RegionFilter"; +import SearchBar from "../../SearchBar"; + +interface SearchResultsContentInnerProps { + homeUniversityName: HomeUniversityName; +} + +// --- URL 파라미터를 읽고 데이터를 처리하는 메인 컨텐츠 --- +const SearchResultsContentInner = ({ homeUniversityName }: SearchResultsContentInnerProps) => { + const searchParams = useSearchParams(); + + // 지역 상태 관리 + const [selectedRegion, setSelectedRegion] = useState(RegionEnumExtend.ALL); + // 지역 변경 핸들러 + const handleRegionChange = (region: RegionEnumExtend) => { + setSelectedRegion(region); + }; + + const { isTextSearch, searchText, filterParams } = useMemo(() => { + const text = searchParams.get("searchText"); + const lang = searchParams.get("languageTestType"); + const countries = searchParams.getAll("countryCode"); + + // URL에서 전달된 국가 목록을 기본으로 사용 + const filteredCountries = countries as CountryCode[]; + + if (!lang || !countries) { + return { + isTextSearch: true, + searchText: text, + filterParams: {} as UniversitySearchFilterParams, + }; + } + return { + isTextSearch: false, + searchText: "", + filterParams: { + languageTestType: (lang as LanguageTestType) || undefined, + countryCode: filteredCountries.length > 0 ? filteredCountries : undefined, + }, + }; + }, [searchParams]); + + const textSearchQuery = useUniversitySearch(searchText ?? "", homeUniversityName); + const filterSearchQuery = useGetUniversitySearchByFilter(filterParams, homeUniversityName); + + const { data: searchResult } = isTextSearch ? textSearchQuery : filterSearchQuery; + + // homeUniversityName과 지역 필터링된 데이터 + const filteredData = useMemo(() => { + if (!searchResult) return searchResult; + + let filtered = searchResult; + + // 지역 필터링 + if (selectedRegion !== RegionEnumExtend.ALL) { + filtered = filtered.filter((university) => university.region === selectedRegion); + } + + return filtered; + }, [searchResult, selectedRegion]); + + // 초기 URL에서 지역 파라미터 읽기 + React.useEffect(() => { + const region = searchParams.get("region"); + if (region && Object.values(RegionEnumExtend).includes(region as RegionEnumExtend)) { + setSelectedRegion(region as RegionEnumExtend); + } + }, [searchParams]); + + return ( +
+ + + {/* 지역 필터 */} + + + {/* 결과 표시 */} + {!filteredData || filteredData.length === 0 ? ( +
검색 결과가 없습니다.
+ ) : ( + + )} + +
+ ); +}; + +interface SearchResultsContentProps { + homeUniversityName: HomeUniversityName; +} + +export default function SearchResultsContent({ homeUniversityName }: SearchResultsContentProps) { + return ( + }> + + + ); +} diff --git a/apps/web/src/app/university/list/[homeUniversityName]/page.tsx b/apps/web/src/app/university/list/[homeUniversityName]/page.tsx new file mode 100644 index 00000000..8d1e550f --- /dev/null +++ b/apps/web/src/app/university/list/[homeUniversityName]/page.tsx @@ -0,0 +1,59 @@ +import type { Metadata } from "next"; +import { notFound } from "next/navigation"; + +import TopDetailNavigation from "@/components/layout/TopDetailNavigation"; +import { HOME_UNIVERSITIES, HOME_UNIVERSITY_SLUG, type HomeUniversityName } from "@/types/university"; + +import SearchResultsContent from "./SearchResultsContent"; + +// ISR: 정적 페이지 생성 +export const revalidate = false; + +// 정적 경로 생성 (ISR) +export async function generateStaticParams() { + return HOME_UNIVERSITIES.map((university) => ({ + homeUniversityName: university.slug, + })); +} + +type PageProps = { + params: Promise<{ homeUniversityName: string }>; +}; + +export async function generateMetadata({ params }: PageProps): Promise { + const { homeUniversityName } = await params; + + const universityName = HOME_UNIVERSITY_SLUG[homeUniversityName]; + + if (!universityName) { + return { + title: "파견 학교 목록", + }; + } + + return { + title: `${universityName} 파견 학교 목록 | 솔리드커넥션`, + description: `${universityName}에서 파견 가능한 교환학생 대학교 목록입니다. 지역별, 어학 요건별로 검색하고 관심있는 대학을 찾아보세요.`, + }; +} + +const UniversityListPage = async ({ params }: PageProps) => { + const { homeUniversityName } = await params; + + const universityName = HOME_UNIVERSITY_SLUG[homeUniversityName] as HomeUniversityName | undefined; + + if (!universityName) { + notFound(); + } + + return ( + <> + +
+ +
+ + ); +}; + +export default UniversityListPage; diff --git a/apps/web/src/app/university/page.tsx b/apps/web/src/app/university/page.tsx index 6b71908f..cb02aa6e 100644 --- a/apps/web/src/app/university/page.tsx +++ b/apps/web/src/app/university/page.tsx @@ -1,22 +1,61 @@ import type { Metadata } from "next"; +import Image from "next/image"; +import Link from "next/link"; import TopDetailNavigation from "@/components/layout/TopDetailNavigation"; - -import SearchResultsContent from "./SearchResultsContent"; +import CheveronRightFilled from "@/components/ui/icon/ChevronRightFilled"; +import { HOME_UNIVERSITIES } from "@/types/university"; export const metadata: Metadata = { - title: "파견 학교 목록", + title: "파견 학교 목록 | 대학교 선택", + description: + "교환학생 파견 대학을 선택하세요. 인하대학교, 인천대학교, 성신여자대학교의 교환학생 프로그램 정보를 확인할 수 있습니다.", }; -const Page = async () => { +// ISR: 정적 페이지 생성 +export const revalidate = false; + +const UniversityOnboardingPage = () => { return ( <> - -
- + +
+

파견 대학교를 선택해주세요

+

+ 소속 대학교를 선택하면 해당 대학교의 교환학생 파견 정보를 확인할 수 있습니다. +

+ +
+ {HOME_UNIVERSITIES.map((university) => ( + +
+
+
+
+ {`${university.name} +
+
+

{university.name}

+

{university.description}

+
+
+
+ +
+
+
+ + ))} +
); }; -export default Page; +export default UniversityOnboardingPage; diff --git a/apps/web/src/types/university.ts b/apps/web/src/types/university.ts index 5274882a..e9602d11 100644 --- a/apps/web/src/types/university.ts +++ b/apps/web/src/types/university.ts @@ -1,5 +1,63 @@ export type RegionKo = "유럽권" | "미주권" | "아시아권"; +/** + * 홈 대학교 이름 (협정 대학) + */ +export enum HomeUniversityName { + INHA = "인하대학교", + INCHEON = "인천대학교", + SUNGSHIN = "성신여자대학교", +} + +/** + * 홈 대학교 URL 슬러그 매핑 + */ +export const HOME_UNIVERSITY_SLUG: Record = { + inha: HomeUniversityName.INHA, + incheon: HomeUniversityName.INCHEON, + sungshin: HomeUniversityName.SUNGSHIN, +}; + +/** + * 홈 대학교 이름에서 슬러그로 매핑 + */ +export const HOME_UNIVERSITY_TO_SLUG: Record = { + [HomeUniversityName.INHA]: "inha", + [HomeUniversityName.INCHEON]: "incheon", + [HomeUniversityName.SUNGSHIN]: "sungshin", +}; + +/** + * 홈 대학교 정보 (이미지, 설명 등) + */ +export interface HomeUniversityInfo { + name: HomeUniversityName; + slug: string; + imageUrl: string; + description: string; +} + +export const HOME_UNIVERSITIES: HomeUniversityInfo[] = [ + { + name: HomeUniversityName.INHA, + slug: "inha", + imageUrl: "/images/unvis/inha.png", + description: "인하대학교 파견 교환학생 정보", + }, + { + name: HomeUniversityName.INCHEON, + slug: "incheon", + imageUrl: "/images/unvis/incheon.png", + description: "인천대학교 파견 교환학생 정보", + }, + { + name: HomeUniversityName.SUNGSHIN, + slug: "sungshin", + imageUrl: "/images/unvis/sungsin.jpg", + description: "성신여자대학교 파견 교환학생 정보", + }, +]; + export interface RegionOption { value: string; label: string; @@ -77,6 +135,7 @@ export interface ListUniversity { backgroundImageUrl: string; studentCapacity: number; languageRequirements: LanguageRequirement[]; + homeUniversityName?: HomeUniversityName; } /**