Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file added apps/web/public/images/unvis/incheon.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added apps/web/public/images/unvis/inha.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added apps/web/public/images/unvis/sungsin.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 2 additions & 0 deletions apps/web/src/apis/universities/api.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { HomeUniversityName } from "@/types/university";
import { axiosInstance, publicAxiosInstance } from "@/utils/axiosInstance";

export interface RecommendedUniversitiesResponseRecommendedUniversitiesItem {
Expand Down Expand Up @@ -95,6 +96,7 @@ export interface SearchTextResponseUnivApplyInfoPreviewsItem {
backgroundImageUrl: string;
studentCapacity: number;
languageRequirements: SearchTextResponseUnivApplyInfoPreviewsItemLanguageRequirementsItem[];
homeUniversityName?: HomeUniversityName;
}

export interface SearchTextResponseUnivApplyInfoPreviewsItemLanguageRequirementsItem {
Expand Down
29 changes: 25 additions & 4 deletions apps/web/src/apis/universities/getSearchFilter.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -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<string, any> = {};
Expand All @@ -30,15 +40,26 @@ const useGetUniversitySearchByFilter = (filters: UniversitySearchFilterParams) =
return params;
};

return useQuery<SearchFilterResponse, AxiosError, ListUniversity[]>({
const query = useQuery<SearchFilterResponse, AxiosError, ListUniversityWithHome[]>({
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;
36 changes: 25 additions & 11 deletions apps/web/src/apis/universities/getSearchText.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<SearchTextResponse, AxiosError, ListUniversity[]>({
} = useQuery<SearchTextResponse, AxiosError, ListUniversityWithHome[]>({
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,
Expand Down
Original file line number Diff line number Diff line change
@@ -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>(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,
};
}
Comment on lines +36 to +47
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

1. 배열 falsy 체크 버그

countriessearchParams.getAll()의 반환값으로 항상 배열입니다. 빈 배열 []도 JavaScript에서 truthy이므로 !countries 조건은 항상 false입니다.

현재 로직에서는 lang만 없으면 텍스트 검색 모드로 전환되는데, 의도한 동작인지 확인이 필요합니다.

🐛 수정 제안
-    if (!lang || !countries) {
+    if (!lang || countries.length === 0) {
🤖 Prompt for AI Agents
In
`@apps/web/src/app/university/list/`[homeUniversityName]/SearchResultsContent.tsx
around lines 36 - 47, The check `if (!lang || !countries)` is incorrect because
`countries` from `searchParams.getAll()` is always an array (even empty), so
replace the falsy array check with an explicit length check: treat text-search
mode when `lang` is missing or `countries.length === 0`; update the conditional
in the block that references `countries`/`filteredCountries` accordingly and
keep the `filteredCountries` cast to `CountryCode[]` (from
`searchParams.getAll`) intact so types remain correct.

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 (
<div className="pt-4">
<SearchBar initText={searchText || undefined} />

{/* 지역 필터 */}
<RegionFilter selectedRegion={selectedRegion} onRegionChange={handleRegionChange} />

{/* 결과 표시 */}
{!filteredData || filteredData.length === 0 ? (
<div className="p-5 text-center text-gray-500">검색 결과가 없습니다.</div>
) : (
<UniversityCards colleges={filteredData} className="mt-3" />
)}
<FloatingUpBtn />
</div>
);
};

interface SearchResultsContentProps {
homeUniversityName: HomeUniversityName;
}

export default function SearchResultsContent({ homeUniversityName }: SearchResultsContentProps) {
return (
<Suspense fallback={<CloudSpinnerPage />}>
<SearchResultsContentInner homeUniversityName={homeUniversityName} />
</Suspense>
);
}
59 changes: 59 additions & 0 deletions apps/web/src/app/university/list/[homeUniversityName]/page.tsx
Original file line number Diff line number Diff line change
@@ -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<Metadata> {
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 (
<>
<TopDetailNavigation title={`${universityName} 파견학교`} />
<div className="mt-14 w-full px-5">
<SearchResultsContent homeUniversityName={universityName} />
</div>
</>
);
};

export default UniversityListPage;
55 changes: 47 additions & 8 deletions apps/web/src/app/university/page.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<>
<TopDetailNavigation title="파견학교 검색" />
<div className="w-full px-5">
<SearchResultsContent />
<TopDetailNavigation title="대학교 선택" />
<div className="mt-14 w-full px-5 py-6">
<h1 className="mb-2 text-k-800 typo-bold-1">파견 대학교를 선택해주세요</h1>
<p className="mb-6 text-k-500 typo-medium-4">
소속 대학교를 선택하면 해당 대학교의 교환학생 파견 정보를 확인할 수 있습니다.
</p>

<div className="flex flex-col gap-2.5">
{HOME_UNIVERSITIES.map((university) => (
<Link key={university.slug} href={`/university/list/${university.slug}`} className="block">
<div className="relative h-[91px] w-full overflow-hidden rounded-lg border border-solid border-k-100 hover:-translate-y-0.5 hover:shadow-md hover:shadow-black/10">
<div className="flex justify-between px-5 py-3.5">
<div className="flex gap-[23.5px]">
<div className="flex flex-shrink-0 items-center">
<Image
src={university.imageUrl}
alt={`${university.name} 이미지`}
width={56}
height={56}
className="h-14 w-14 rounded-full object-cover"
/>
</div>
<div className="flex flex-col justify-center">
<h2 className="text-k-700 typo-bold-4">{university.name}</h2>
<p className="text-k-500 typo-medium-4">{university.description}</p>
</div>
</div>
<div className="flex items-center">
<CheveronRightFilled color="black" opacity="0.54" />
</div>
</div>
</div>
</Link>
))}
</div>
</div>
</>
);
};

export default Page;
export default UniversityOnboardingPage;
Loading