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
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@
**/build
**/dist

# generated api schema
packages/api-schema/src/apis/
packages/api-schema/.cache/

# misc
.DS_Store
*.pem
Expand Down
4 changes: 2 additions & 2 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
"analyze": "ANALYZE=true next build"
},
"dependencies": {
"@hookform/resolvers": "^5.1.1",
"@hookform/resolvers": "^5.2.2",
"@next/third-parties": "^14.2.4",
"@radix-ui/react-checkbox": "^1.1.4",
"@radix-ui/react-label": "^2.1.2",
Expand Down Expand Up @@ -44,7 +44,7 @@
"sockjs-client": "^1.6.1",
"tailwind-merge": "^3.0.2",
"tailwindcss-animate": "^1.0.7",
"zod": "^4.0.5",
"zod": "^4.0.0",
"zustand": "^5.0.7"
},
"devDependencies": {
Expand Down
Binary file added apps/web/public/images/univs/sungshin.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
9 changes: 3 additions & 6 deletions apps/web/src/apis/mentor/getUnconfirmedMentoringCount.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,15 @@ import { useQuery } from "@tanstack/react-query";
import type { AxiosError } from "axios";
import { type GetMentoringNewCountResponse, MentorQueryKeys, mentorApi } from "./api";

/**
* @description 미확인 멘토링 수 조회 훅
*/
const useGetMentoringUncheckedCount = (isEnable: boolean) => {
const useGetUnconfirmedMentoringCount = (enabled: boolean = true) => {
return useQuery<GetMentoringNewCountResponse, AxiosError, number>({
queryKey: [MentorQueryKeys.mentoringNewCount],
queryFn: mentorApi.getMentoringUncheckedCount,
enabled: isEnable,
enabled,
refetchInterval: 1000 * 60 * 10, // ⏱️ 10분마다 자동 재요청
staleTime: 1000 * 60 * 5, // fresh 상태 유지
select: (data) => data.uncheckedCount,
});
};

export default useGetMentoringUncheckedCount;
export default useGetUnconfirmedMentoringCount;
5 changes: 1 addition & 4 deletions apps/web/src/apis/mentor/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,15 +9,12 @@ export type {
VerifyStatus,
} from "./api";
export { MentorQueryKeys, mentorApi } from "./api";
// Mentee (멘티) hooks
export { default as useGetApplyMentoringList, usePrefetchApplyMentoringList } from "./getAppliedMentorings";
export { default as useGetMentorDetail } from "./getMentorDetail";
// Mentors (멘토 목록) hooks
export { default as useGetMentorList, usePrefetchMentorList } from "./getMentorList";
// Mentor (멘토) hooks
export { default as useGetMentorMyProfile } from "./getMyMentorPage";
export { default as useGetMentoringList } from "./getReceivedMentorings";
export { default as useGetMentoringUncheckedCount } from "./getUnconfirmedMentoringCount";
export { default as useGetUnconfirmedMentoringCount } from "./getUnconfirmedMentoringCount";
export { default as usePatchMentorCheckMentorings } from "./patchConfirmMentoring";
export { default as usePatchMenteeCheckMentorings } from "./patchMenteeCheckMentorings";
export { default as usePatchApprovalStatus } from "./patchMentoringStatus";
Expand Down
63 changes: 63 additions & 0 deletions apps/web/src/app/university/(home)/_ui/HomeUniversityCard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
"use client";

import Image from "next/image";
import Link from "next/link";

import type { HomeUniversityInfo } from "@/constants/university";

interface HomeUniversityCardProps {
university: HomeUniversityInfo;
}

const HomeUniversityCard = ({ university }: HomeUniversityCardProps) => {
return (
<Link
href={`/university/${university.slug}`}
className="group flex items-center gap-5 rounded-2xl border border-k-100 bg-white p-5 transition-all hover:-translate-y-0.5 hover:border-primary-300 hover:shadow-lg"
>
<div
className="flex h-16 w-16 flex-shrink-0 items-center justify-center overflow-hidden rounded-full bg-k-50"
style={{ backgroundColor: `${university.color}10` }}
>
<Image
src={university.logoUrl}
alt={`${university.name} 로고`}
width={48}
height={48}
className="h-12 w-12 object-contain"
onError={(e) => {
// 이미지 로드 실패 시 기본 텍스트 표시
const target = e.target as HTMLImageElement;
target.style.display = "none";
}}
/>
</div>

<div className="flex flex-1 flex-col">
<span className="text-k-800 typo-bold-3 group-hover:text-primary">{university.name}</span>
<span className="text-k-500 typo-medium-4">{university.description}</span>
</div>

<div className="flex h-8 w-8 items-center justify-center rounded-full bg-k-50 transition-colors group-hover:bg-primary-100">
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className="text-k-400 group-hover:text-primary"
>
<path
d="M6 12L10 8L6 4"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</div>
</Link>
);
};

export default HomeUniversityCard;
18 changes: 18 additions & 0 deletions apps/web/src/app/university/(home)/layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import type { ReactNode } from "react";

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

interface LayoutProps {
children: ReactNode;
}

const UniversityHomeLayout = ({ children }: LayoutProps) => {
return (
<>
<TopDetailNavigation title="대학교 선택" />
{children}
</>
);
};

export default UniversityHomeLayout;
35 changes: 35 additions & 0 deletions apps/web/src/app/university/(home)/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import type { Metadata } from "next";

import { HOME_UNIVERSITY_LIST } from "@/constants/university";

import HomeUniversityCard from "./_ui/HomeUniversityCard";

export const revalidate = 3600; // 1시간마다 재검증 (ISR)

export const metadata: Metadata = {
title: "대학 선택 | 솔리드커넥션",
description: "소속 대학교를 선택하여 교환학생 정보를 확인하세요.",
};

const UniversitySelectPage = () => {
return (
<div className="flex min-h-[calc(100vh-112px)] flex-col px-5 py-8">
<div className="mb-8 text-center">
<h1 className="mb-2 text-k-900 typo-bold-1">소속 대학교 선택</h1>
<p className="text-k-500 typo-medium-4">
소속 대학교를 선택하면
<br />
해당 대학의 교환학생 정보를 확인할 수 있습니다.
</p>
</div>

<div className="flex flex-1 flex-col gap-4">
{HOME_UNIVERSITY_LIST.map((university) => (
<HomeUniversityCard key={university.slug} university={university} />
))}
</div>
</div>
);
};

export default UniversitySelectPage;
Original file line number Diff line number Diff line change
@@ -1,54 +1,75 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";

import { getAllUniversities, getUniversityDetail } from "@/apis/universities/server";
import TopDetailNavigation from "@/components/layout/TopDetailNavigation";
import { getHomeUniversityBySlug, HOME_UNIVERSITY_SLUGS } from "@/constants/university";
import type { HomeUniversitySlug } from "@/types/university";

// UniversityDetail 컴포넌트
import UniversityDetail from "./_ui/UniversityDetail";

export const revalidate = false;
export const revalidate = false; // 완전 정적 생성

// 모든 homeUniversity + id 조합에 대해 정적 경로 생성
export async function generateStaticParams() {
const universities = await getAllUniversities();

return universities.map((university) => ({
id: String(university.id),
}));
const params: { homeUniversity: string; id: string }[] = [];

// 각 대학에 대해 모든 homeUniversity 슬러그와 조합
for (const slug of HOME_UNIVERSITY_SLUGS) {
const homeUniversityInfo = getHomeUniversityBySlug(slug);
if (!homeUniversityInfo) continue;

// 해당 홈대학에 속하는 대학들만 필터링
const filteredUniversities = universities.filter((uni) => uni.homeUniversityName === homeUniversityInfo.name);

for (const university of filteredUniversities) {
params.push({
homeUniversity: slug,
id: String(university.id),
});
}
}

return params;
}

type MetadataProps = {
params: Promise<{ id: string }>;
type PageProps = {
params: Promise<{ homeUniversity: string; id: string }>;
};

export async function generateMetadata({ params }: MetadataProps): Promise<Metadata> {
const { id } = await params;
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
const { homeUniversity, id } = await params;

// 유효한 슬러그인지 확인
if (!HOME_UNIVERSITY_SLUGS.includes(homeUniversity as HomeUniversitySlug)) {
return { title: "파견 학교 상세" };
}

const universityData = await getUniversityDetail(Number(id));

if (!universityData) {
return {
title: "파견 학교 상세",
};
return { title: "파견 학교 상세" };
}

const homeUniversityInfo = getHomeUniversityBySlug(homeUniversity);
const convertedKoreanName =
universityData.term !== process.env.NEXT_PUBLIC_CURRENT_TERM
? `${universityData.koreanName}(${universityData.term})`
: universityData.koreanName;

const baseUrl = process.env.NEXT_PUBLIC_WEB_URL || "https://solid-connection.com";
const pageUrl = `${baseUrl}/university/${id}`;
const pageUrl = `${baseUrl}/university/${homeUniversity}/${id}`;
const imageUrl = universityData.backgroundImageUrl
? universityData.backgroundImageUrl.startsWith("http")
? universityData.backgroundImageUrl
: `${baseUrl}${universityData.backgroundImageUrl}`
: `${baseUrl}/images/article-thumb.png`;

// [나라] 교환학생 키워드 생성
const countryExchangeKeyword = `${universityData.country} 교환학생`;

// Description 생성: 대학교 이름과 [나라] 교환학생 키워드 포함
const description = `${convertedKoreanName}(${universityData.englishName}) ${countryExchangeKeyword} 프로그램. 모집인원 ${universityData.studentCapacity}명. 솔리드커넥션에서 ${convertedKoreanName} ${countryExchangeKeyword} 지원 정보 확인.`;

// Title 생성: 대학교 이름과 [나라] 교환학생 키워드 포함 (검색 최적화)
const description = `${convertedKoreanName}(${universityData.englishName}) ${countryExchangeKeyword} 프로그램. 모집인원 ${universityData.studentCapacity}명. ${homeUniversityInfo?.shortName || ""} 학생을 위한 교환학생 정보.`;
const title = `${convertedKoreanName} - ${countryExchangeKeyword} 정보 | 솔리드커넥션`;

return {
Expand Down Expand Up @@ -82,13 +103,20 @@ export async function generateMetadata({ params }: MetadataProps): Promise<Metad
};
}

type CollegeDetailPageProps = {
params: { id: string };
};
const CollegeDetailPage = async ({ params }: PageProps) => {
const { homeUniversity, id } = await params;

const CollegeDetailPage = async ({ params }: CollegeDetailPageProps) => {
const collegeId = Number(params.id);
// 유효한 슬러그인지 확인
if (!HOME_UNIVERSITY_SLUGS.includes(homeUniversity as HomeUniversitySlug)) {
notFound();
}

const homeUniversityInfo = getHomeUniversityBySlug(homeUniversity);
if (!homeUniversityInfo) {
notFound();
}

const collegeId = Number(id);
const universityData = await getUniversityDetail(collegeId);

if (!universityData) {
Expand All @@ -101,21 +129,10 @@ const CollegeDetailPage = async ({ params }: CollegeDetailPageProps) => {
: universityData.koreanName;

const baseUrl = process.env.NEXT_PUBLIC_WEB_URL || "https://solid-connection.com";
const pageUrl = `${baseUrl}/university/${collegeId}`;

// [나라] 교환학생 키워드 생성
const pageUrl = `${baseUrl}/university/${homeUniversity}/${collegeId}`;
const countryExchangeKeyword = `${universityData.country} 교환학생`;

// Structured Data (JSON-LD) for SEO - 검색 엔진이 대학 정보를 더 잘 이해하도록
const structuredData: {
"@context": string;
"@type": string;
name: string;
alternateName?: string;
url: string;
description: string;
image: string;
} = {
const structuredData = {
"@context": "https://schema.org",
"@type": "EducationalOrganization",
name: convertedKoreanName,
Expand All @@ -132,7 +149,7 @@ const CollegeDetailPage = async ({ params }: CollegeDetailPageProps) => {
return (
<>
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(structuredData) }} />
<TopDetailNavigation title={convertedKoreanName} />
<TopDetailNavigation title={convertedKoreanName} backHref={`/university/${homeUniversity}`} />
<div className="w-full px-5">
<UniversityDetail koreanName={convertedKoreanName} university={universityData} />
</div>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
"use client";

import clsx from "clsx";

import { RegionEnumExtend } from "@/types/university";

const REGIONS = [
{ value: "전체", label: "전체" },
{ value: RegionEnumExtend.AMERICAS, label: "미주권" },
{ value: RegionEnumExtend.EUROPE, label: "유럽권" },
{ value: RegionEnumExtend.ASIA, label: "아시아권" },
{ value: RegionEnumExtend.CHINA, label: "중국권" },
] as const;

interface RegionFilterProps {
selectedRegion: RegionEnumExtend | "전체";
onRegionChange: (region: RegionEnumExtend | "전체") => void;
}

const RegionFilter = ({ selectedRegion, onRegionChange }: RegionFilterProps) => {
return (
<div className="mb-4 flex gap-2 overflow-x-auto pb-1">
{REGIONS.map(({ value, label }) => (
<button
key={value}
type="button"
onClick={() => onRegionChange(value)}
className={clsx(
"whitespace-nowrap rounded-full border px-4 py-2 transition-colors typo-medium-4",
selectedRegion === value
? "border-primary bg-primary-100 text-primary"
: "border-k-100 bg-k-50 text-k-500 hover:border-k-200 hover:bg-k-100",
)}
>
{label}
</button>
))}
</div>
);
};

export default RegionFilter;
Loading
Loading