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
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"use client";

import { useMemo, useState } from "react";
import { useEffect, useMemo, useState } from "react";

import FloatingUpBtn from "@/components/ui/FloatingUpBtn";
import UniversityCards from "@/components/university/UniversityCards";
Expand All @@ -14,11 +14,27 @@ interface UniversityListContentProps {
universities: ListUniversity[];
homeUniversity: HomeUniversityInfo;
homeUniversitySlug: string;
initialSearchText?: string;
initialRegion?: RegionEnumExtend | "전체";
}

const UniversityListContent = ({ universities, homeUniversity, homeUniversitySlug }: UniversityListContentProps) => {
const [searchText, setSearchText] = useState("");
const [selectedRegion, setSelectedRegion] = useState<RegionEnumExtend | "전체">("전체");
const UniversityListContent = ({
universities,
homeUniversity,
homeUniversitySlug,
initialSearchText = "",
initialRegion = "전체",
}: UniversityListContentProps) => {
const [searchText, setSearchText] = useState(initialSearchText.trim());
const [selectedRegion, setSelectedRegion] = useState<RegionEnumExtend | "전체">(initialRegion);

useEffect(() => {
setSearchText(initialSearchText.trim());
}, [initialSearchText]);

useEffect(() => {
setSelectedRegion(initialRegion);
}, [initialRegion]);

// 검색어 및 지역 필터링
const filteredUniversities = useMemo(() => {
Expand Down
75 changes: 68 additions & 7 deletions apps/web/src/app/university/[homeUniversity]/page.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,15 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";

import { getSearchUniversitiesAllRegions } from "@/apis/universities/server";
import { getSearchUniversitiesAllRegions, getSearchUniversitiesByFilter } from "@/apis/universities/server";
import TopDetailNavigation from "@/components/layout/TopDetailNavigation";
import { getHomeUniversityBySlug, HOME_UNIVERSITY_SLUGS, isMatchedHomeUniversityName } from "@/constants/university";
import type { HomeUniversitySlug } from "@/types/university";
import {
COUNTRY_CODE_MAP,
getHomeUniversityBySlug,
HOME_UNIVERSITY_SLUGS,
isMatchedHomeUniversityName,
} from "@/constants/university";
import { type CountryCode, type HomeUniversitySlug, LanguageTestType, RegionEnumExtend } from "@/types/university";

import UniversityListContent from "./_ui/UniversityListContent";

Expand All @@ -19,6 +24,42 @@ export async function generateStaticParams() {

type PageProps = {
params: Promise<{ homeUniversity: string }>;
searchParams?: Record<string, string | string[] | undefined>;
};

type SearchParamValue = string | string[] | undefined;

const getSearchParamValues = (value: SearchParamValue): string[] => {
if (!value) {
return [];
}

return Array.isArray(value) ? value : [value];
};

const getFirstSearchParamValue = (value: SearchParamValue): string => {
if (Array.isArray(value)) {
return value[0] ?? "";
}

return value ?? "";
};

const isCountryCode = (value: string): value is CountryCode => {
return Object.hasOwn(COUNTRY_CODE_MAP, value);
};

const isLanguageTestType = (value: string): value is LanguageTestType => {
return Object.values(LanguageTestType).includes(value as LanguageTestType);
};

const isRegionFilterValue = (value: string): value is RegionEnumExtend => {
return (
value === RegionEnumExtend.AMERICAS ||
value === RegionEnumExtend.EUROPE ||
value === RegionEnumExtend.ASIA ||
value === RegionEnumExtend.CHINA
);
};

export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
Expand All @@ -37,8 +78,14 @@ export async function generateMetadata({ params }: PageProps): Promise<Metadata>
};
}

const UniversityListPage = async ({ params }: PageProps) => {
const UniversityListPage = async ({ params, searchParams }: PageProps) => {
const { homeUniversity } = await params;
const initialSearchText = getFirstSearchParamValue(searchParams?.searchText).trim();
const languageTestTypeParam = getFirstSearchParamValue(searchParams?.languageTestType).trim();
const countryCodeParams = getSearchParamValues(searchParams?.countryCode).filter(isCountryCode);
const regionParams = getSearchParamValues(searchParams?.region).filter(isRegionFilterValue);
const initialRegion = regionParams.length === 1 ? regionParams[0] : RegionEnumExtend.ALL;
const languageTestType = isLanguageTestType(languageTestTypeParam) ? languageTestTypeParam : undefined;

// 유효한 슬러그인지 확인
if (!HOME_UNIVERSITY_SLUGS.includes(homeUniversity as HomeUniversitySlug)) {
Expand All @@ -51,21 +98,35 @@ const UniversityListPage = async ({ params }: PageProps) => {
notFound();
}

// 전체 대학 목록을 서버에서 가져옴 (ISR 캐시됨)
const allUniversities = await getSearchUniversitiesAllRegions();
const shouldUseFilterApi = Boolean(languageTestType) || countryCodeParams.length > 0;

const allUniversities = shouldUseFilterApi
? await getSearchUniversitiesByFilter({
languageTestType,
countryCode: countryCodeParams.length > 0 ? countryCodeParams : undefined,
})
: await getSearchUniversitiesAllRegions();

// homeUniversityName으로 프론트에서 필터링
const filteredUniversities = allUniversities.filter((university) =>
let filteredUniversities = allUniversities.filter((university) =>
isMatchedHomeUniversityName(university.homeUniversityName, universityInfo.name),
);

if (regionParams.length > 0) {
filteredUniversities = filteredUniversities.filter((university) =>
regionParams.includes(university.region as RegionEnumExtend),
);
}

return (
<>
<TopDetailNavigation title={`${universityInfo.shortName} 파견학교`} backHref="/university" />
<UniversityListContent
universities={filteredUniversities}
homeUniversity={universityInfo}
homeUniversitySlug={homeUniversity}
initialSearchText={initialSearchText}
initialRegion={initialRegion}
/>
</>
);
Expand Down
10 changes: 7 additions & 3 deletions apps/web/src/app/university/search/PageContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import {
REGION_TO_COUNTRIES_MAP,
REGIONS_SEARCH,
} from "@/constants/university";
import { CountryCode, LanguageTestType } from "@/types/university";
import { CountryCode, type HomeUniversitySlug, LanguageTestType } from "@/types/university";
import CustomDropdown from "../CustomDropdown";

// --- 커스텀 드롭다운 컴포넌트 ---
Expand All @@ -30,8 +30,12 @@ const searchSchema = z.object({
});
type SearchFormData = z.infer<typeof searchSchema>;

interface SchoolSearchFormProps {
homeUniversitySlug: HomeUniversitySlug;
}

// --- 메인 폼 컴포넌트 ---
const SchoolSearchForm = () => {
const SchoolSearchForm = ({ homeUniversitySlug }: SchoolSearchFormProps) => {
const router = useRouter();

const { handleSubmit, control, watch, setValue } = useForm<SearchFormData>({
Expand Down Expand Up @@ -59,7 +63,7 @@ const SchoolSearchForm = () => {
});

const queryString = queryParams.toString();
router.push(`/university?${queryString}`);
router.push(`/university/${homeUniversitySlug}?${queryString}`);
};

const availableCountries = useMemo(() => {
Expand Down
6 changes: 4 additions & 2 deletions apps/web/src/app/university/search/SearchBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { zodResolver } from "@hookform/resolvers/zod";
import { useRouter } from "next/navigation";
import { type SubmitHandler, useForm } from "react-hook-form";
import { z } from "zod";
import type { HomeUniversitySlug } from "@/types/university";

const searchSchema = z.object({
searchText: z.string().min(1, "검색어를 입력해주세요.").max(50, "최대 50자까지 입력 가능합니다."),
Expand All @@ -23,9 +24,10 @@ const SearchIcon = () => (

interface SearchBarProps {
initText?: string;
homeUniversitySlug: HomeUniversitySlug;
}
// --- 폼 로직을 관리하는 부모 컴포넌트 ---
const SearchBar = ({ initText }: SearchBarProps) => {
const SearchBar = ({ initText, homeUniversitySlug }: SearchBarProps) => {
const router = useRouter();

const {
Expand All @@ -47,7 +49,7 @@ const SearchBar = ({ initText }: SearchBarProps) => {
}

const queryString = queryParams.toString();
router.push(`/university?${queryString}`);
router.push(`/university/${homeUniversitySlug}?${queryString}`);
};

return (
Expand Down
49 changes: 49 additions & 0 deletions apps/web/src/app/university/search/SearchClientContent.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
"use client";

import clsx from "clsx";
import { useState } from "react";
import { HOME_UNIVERSITY_LIST, HOME_UNIVERSITY_TO_SLUG_MAP } from "@/constants/university";
import { HomeUniversity, type HomeUniversitySlug } from "@/types/university";
import SchoolSearchForm from "./PageContent";
import SearchBar from "./SearchBar";

const SearchClientContent = () => {
const [selectedHomeUniversitySlug, setSelectedHomeUniversitySlug] = useState<HomeUniversitySlug>(
HOME_UNIVERSITY_TO_SLUG_MAP[HomeUniversity.INHA],
);

return (
<>
<div className="mb-4 flex gap-2 overflow-x-auto">
{HOME_UNIVERSITY_LIST.map((university) => {
const isSelected = university.slug === selectedHomeUniversitySlug;

return (
<button
key={university.slug}
type="button"
onClick={() => setSelectedHomeUniversitySlug(university.slug)}
className={clsx(
"min-w-fit whitespace-nowrap rounded-full border px-4 py-2 transition-colors",
isSelected
? "border-primary bg-primary-100 text-primary-900"
: "border-k-50 bg-k-50 text-k-300 hover:border-k-100 hover:bg-k-100",
)}
aria-pressed={isSelected}
>
{university.shortName}
</button>
);
})}
</div>

<div className="relative mb-4">
<SearchBar homeUniversitySlug={selectedHomeUniversitySlug} />
</div>

<SchoolSearchForm homeUniversitySlug={selectedHomeUniversitySlug} />
</>
);
};

export default SearchClientContent;
8 changes: 2 additions & 6 deletions apps/web/src/app/university/search/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,7 @@ import dynamic from "next/dynamic";

import TopDetailNavigation from "@/components/layout/TopDetailNavigation";

const SearchBar = dynamic(() => import("./SearchBar"), { ssr: false });
const SchoolSearchForm = dynamic(() => import("./PageContent"), { ssr: false });
const SearchClientContent = dynamic(() => import("./SearchClientContent"), { ssr: false });

export const metadata: Metadata = {
title: "파견 학교 목록",
Expand All @@ -18,10 +17,7 @@ const Page = async () => {
<main className="flex flex-1 flex-col p-5">
<h2 className="mb-1 typo-bold-1">오직 나를 위한</h2>
<h2 className="mb-6 typo-bold-1">맞춤 파견 학교 찾기</h2>
<div className="relative mb-4">
<SearchBar />
</div>
<SchoolSearchForm />
<SearchClientContent />
</main>
</div>
</>
Expand Down