From 610e4cfa7e8fe977ab47d6b3ae25fe51aaf98935 Mon Sep 17 00:00:00 2001 From: manNomi Date: Sat, 4 Jul 2026 01:43:01 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=A7=80=EC=9B=90=EC=9E=90=20=ED=98=84?= =?UTF-8?q?=ED=99=A9=20=ED=99=95=EC=9D=B8=20=EB=94=94=EC=9E=90=EC=9D=B8=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/web/next.config.mjs | 2 +- apps/web/package.json | 1 + .../application/ScorePageContent.tsx | 478 +++++++++++++----- .../app/university/application/ScoreSheet.tsx | 131 +++-- .../ApplicationUniversityDetailContent.tsx | 290 +++++++++++ .../application/[universityName]/page.tsx | 47 ++ .../src/app/university/application/page.tsx | 4 +- apps/web/src/constants/university.ts | 3 +- apps/web/src/types/application.ts | 9 +- apps/web/src/types/university.ts | 2 +- apps/web/tailwind.config.ts | 2 +- packages/ui/src/index.ts | 2 + packages/ui/src/mobile-hero-detail-shell.tsx | 66 +++ pnpm-lock.yaml | 3 + 14 files changed, 884 insertions(+), 156 deletions(-) create mode 100644 apps/web/src/app/university/application/[universityName]/ApplicationUniversityDetailContent.tsx create mode 100644 apps/web/src/app/university/application/[universityName]/page.tsx create mode 100644 packages/ui/src/mobile-hero-detail-shell.tsx diff --git a/apps/web/next.config.mjs b/apps/web/next.config.mjs index a78cd47e4..87c2a0d0e 100644 --- a/apps/web/next.config.mjs +++ b/apps/web/next.config.mjs @@ -29,7 +29,7 @@ if (isProductionRuntime && !universityWebDomain) { /** @type {import('next').NextConfig} */ const nextConfig = { - transpilePackages: ["@solid-connect/ai-inspector"], + transpilePackages: ["@solid-connect/ai-inspector", "@solid-connect/ui"], reactCompiler: { compilationMode: "annotation", }, diff --git a/apps/web/package.json b/apps/web/package.json index e79f1df0f..82ec35c05 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -26,6 +26,7 @@ "@react-google-maps/api": "^2.19.2", "@sentry/nextjs": "^10.61.0", "@solid-connect/ai-inspector": "workspace:^", + "@solid-connect/ui": "workspace:^", "@stomp/stompjs": "^7.1.1", "@tanstack/react-query": "^5.84.1", "@tanstack/react-query-devtools": "^5.84.1", diff --git a/apps/web/src/app/university/application/ScorePageContent.tsx b/apps/web/src/app/university/application/ScorePageContent.tsx index b44bd1715..27c8e64e6 100644 --- a/apps/web/src/app/university/application/ScorePageContent.tsx +++ b/apps/web/src/app/university/application/ScorePageContent.tsx @@ -1,30 +1,48 @@ "use client"; +import clsx from "clsx"; +import Link from "next/link"; import { useRouter } from "next/navigation"; -import { type FormEvent, useEffect, useMemo, useRef, useState } from "react"; +import { type ReactNode, useEffect, useMemo, useState } from "react"; import { useGetApplicationsList } from "@/apis/applications"; import ConfirmCancelModal from "@/components/modal/ConfirmCancelModal"; -import ButtonTab from "@/components/ui/ButtonTab"; -import Tab from "@/components/ui/Tab"; import { DEFAULT_MAX_CHOICE_COUNT, getHomeUniversityById, REGIONS_KO } from "@/constants/university"; import useAuthStore from "@/lib/zustand/useAuthStore"; -import type { ScoreSheet as ScoreSheetType } from "@/types/application"; +import type { Applicant, ScoreSheet as ScoreSheetType } from "@/types/application"; import type { RegionKo } from "@/types/university"; -import ApplicationSectionTitle from "./_components/ApplicationSectionTitle"; -import ScoreSearchBar from "./ScoreSearchBar"; -import ScoreSearchField from "./ScoreSearchField"; -import ScoreSheet from "./ScoreSheet"; +import { getApplicationDetailHref, MobileScoreSheet } from "./ScoreSheet"; + +type ApplicantScope = "all" | "withApplicants"; +type ScoreSort = "applicants" | "gpa"; + +type AppliedUniversity = { + preference: number; + scoreSheet: ScoreSheetType; +}; + +type ScorePageViewProps = { + appliedUniversities: AppliedUniversity[]; + displayedScoreSheets: ScoreSheetType[]; + totalUniversityCount: number; + applicantUniversityCount: number; + participantCount: number; + scope: ApplicantScope; + regionFilter: RegionKo | ""; + sortMode: ScoreSort; + onScopeChange: (scope: ApplicantScope) => void; + onRegionChange: (region: RegionKo | "") => void; + onSortModeChange: (sortMode: ScoreSort) => void; + onChangeUniversities: () => void; +}; const ScorePageContent = () => { const router = useRouter(); - const searchRef = useRef(null!); const homeUniversityId = useAuthStore((state) => state.homeUniversityId); const maxChoiceCount = getHomeUniversityById(homeUniversityId)?.maxChoiceCount ?? DEFAULT_MAX_CHOICE_COUNT; - const [searchActive, setSearchActive] = useState(false); - const [preferenceIndex, setPreferenceIndex] = useState(0); + const [scope, setScope] = useState("withApplicants"); const [regionFilter, setRegionFilter] = useState(""); - const [searchValue, setSearchValue] = useState(""); + const [sortMode, setSortMode] = useState("applicants"); const [showNeedApply, _setShowNeedApply] = useState(false); const emptyChoices = useMemo( @@ -33,74 +51,31 @@ const ScorePageContent = () => { ); const { data: scoreResponseData, isError, isLoading } = useGetApplicationsList(); const scoreChoices = scoreResponseData?.choices ?? emptyChoices; - const preferenceChoices = useMemo( - () => Array.from({ length: Math.max(scoreChoices.length, maxChoiceCount) }, (_, index) => `${index + 1}순위`), - [maxChoiceCount, scoreChoices.length], - ); - const filteredAndSortedData = useMemo(() => { - // ✨ 1. 대학 이름(koreanName)을 기준으로 중복을 제거하는 헬퍼 함수 - const uniqueByKoreanName = (data: ScoreSheetType[]) => { - // Map을 사용해 koreanName을 키로 하여 중복을 효율적으로 제거합니다. - const universityMap = new Map(data.map((sheet) => [sheet.koreanName, sheet])); - // Map의 값들만 다시 배열로 변환하여 반환합니다. - return Array.from(universityMap.values()); - }; - - // ✨ 2. API 응답 데이터를 받자마자 중복부터 제거하고 정렬합니다. - const sortedData = scoreChoices.map((choice) => - uniqueByKoreanName(choice).sort((a, b) => b.applicants.length - a.applicants.length), - ); + const allScoreSheets = useMemo( + () => sortScoreSheets(uniqueScoreSheets(scoreChoices.flat()), sortMode), + [scoreChoices, sortMode], + ); + const appliedUniversities = useMemo( + () => getAppliedUniversities(scoreChoices, maxChoiceCount), + [scoreChoices, maxChoiceCount], + ); + const totalUniversityCount = allScoreSheets.length; + const applicantUniversityCount = allScoreSheets.filter((scoreSheet) => scoreSheet.applicants.length > 0).length; + const participantCount = useMemo(() => getParticipantCount(allScoreSheets), [allScoreSheets]); - // 4. 기존 필터링 로직을 적용합니다. - const applyFilters = (data: ScoreSheetType[]) => { - let result = data; - if (regionFilter) { - result = result.filter((sheet) => sheet.region === regionFilter); - } - if (searchValue) { - result = result.filter((sheet) => sheet.koreanName.toLowerCase().includes(searchValue.toLowerCase())); - } - return result; - }; - - return sortedData.map(applyFilters); - }, [scoreChoices, regionFilter, searchValue]); - - // (이하 코드는 동일) - const handleSearch = (event: FormEvent) => { - event.preventDefault(); - const keyword = searchRef.current?.value || ""; - setRegionFilter(""); - setSearchValue(keyword); - setSearchActive(false); - }; + const displayedScoreSheets = useMemo(() => { + let result = + scope === "withApplicants" + ? allScoreSheets.filter((scoreSheet) => scoreSheet.applicants.length > 0) + : allScoreSheets; - const handleSearchField = (keyword: string) => { - if (searchRef.current) { - searchRef.current.value = keyword; + if (regionFilter) { + result = result.filter((scoreSheet) => scoreSheet.region === regionFilter); } - setRegionFilter(""); - setSearchValue(keyword); - setSearchActive(false); - }; - - const handleSearchClick = () => { - setSearchActive(true); - }; - - const handlePreferenceChange = (nextPreference: string) => { - const nextIndex = preferenceChoices.indexOf(nextPreference); - setPreferenceIndex(nextIndex >= 0 ? nextIndex : 0); - }; - const selectedPreference = preferenceChoices[preferenceIndex] ?? preferenceChoices[0] ?? "1순위"; - const scoreSheets = filteredAndSortedData[preferenceIndex] ?? []; - - useEffect(() => { - if (preferenceIndex < preferenceChoices.length) return; - setPreferenceIndex(0); - }, [preferenceChoices.length, preferenceIndex]); + return sortScoreSheets(result, sortMode); + }, [allScoreSheets, regionFilter, scope, sortMode]); useEffect(() => { if (isLoading) return; @@ -109,55 +84,328 @@ const ScorePageContent = () => { } }, [isError, isLoading, router]); - const hotKeyWords = ["RMIT", "오스트라바", "칼스루에", "그라츠", "추오", "프라하", "보라스", "빈", "메모리얼"]; + const viewProps = { + appliedUniversities, + displayedScoreSheets, + totalUniversityCount, + applicantUniversityCount, + participantCount, + scope, + regionFilter, + sortMode, + onScopeChange: setScope, + onRegionChange: setRegionFilter, + onSortModeChange: setSortMode, + onChangeUniversities: () => router.push("/university/application/apply"), + }; return ( -
- + + router.push("/")} + handleConfirm={() => router.push("/university/application/apply")} + content={"점수 공유현황을 확인하려면 지원절차를\n진행해주세요."} + cancelText="확인" + approveText="학교 지원하기" /> - + + ); +}; - {searchActive ? ( -
- -
- ) : ( - <> -
- -
- { - if (searchRef.current) searchRef.current.value = ""; - setSearchValue(""); - setRegionFilter(newRegion as RegionKo | ""); - }} - style={{ padding: "10px 0 10px 8px" }} - /> +const ApplicationScoreView = ({ + appliedUniversities, + displayedScoreSheets, + totalUniversityCount, + applicantUniversityCount, + participantCount, + scope, + regionFilter, + sortMode, + onScopeChange, + onRegionChange, + onSortModeChange, + onChangeUniversities, +}: ScorePageViewProps) => { + return ( +
+ +
+ + + + +
+ ); +}; -
- {scoreSheets.map((choice) => ( - - ))} -
- router.push("/")} - handleConfirm={() => router.push("/university/application/apply")} - content={"점수 공유현황을 확인하려면 지원절차를\n진행해주세요."} - cancelText="확인" - approveText="학교 지원하기" +const AppliedUniversitySection = ({ + appliedUniversities, + onChangeUniversities, +}: { + appliedUniversities: AppliedUniversity[]; + onChangeUniversities: () => void; +}) => ( +
+
+

지원한 대학({appliedUniversities.length})

+ +
+
+ {appliedUniversities.length > 0 ? ( + appliedUniversities.map(({ preference, scoreSheet }) => ( + - + )) + ) : ( +
+ 지원한 대학 정보를 불러오는 중입니다. +
)}
+
+); + +const AppliedUniversityRow = ({ preference, scoreSheet }: AppliedUniversity) => { + const capacity = + scoreSheet.studentCapacity === null || scoreSheet.studentCapacity === undefined + ? "미정" + : scoreSheet.studentCapacity; + + return ( + + + + {scoreSheet.koreanName} ({preference}/{capacity}) + + + ˅ + + ); }; +const ParticipantBanner = ({ participantCount }: { participantCount: number }) => ( +
+
+
+ 🔥 +
+
+

솔리드 커넥션과 함께하고 있어요

+

총 {participantCount}명이 성적 공유 참여중!

+
+
+
+); + +const ApplicationScopeTabs = ({ + scope, + totalUniversityCount, + applicantUniversityCount, + onScopeChange, +}: { + scope: ApplicantScope; + totalUniversityCount: number; + applicantUniversityCount: number; + onScopeChange: (scope: ApplicantScope) => void; +}) => ( +
+ onScopeChange("all")}> + 모든 대학 ({totalUniversityCount}) + + onScopeChange("withApplicants")}> + 지원자 있는 대학 ({applicantUniversityCount}) + +
+); + +const ScopeTabButton = ({ + children, + isActive, + onClick, +}: { + children: ReactNode; + isActive: boolean; + onClick: () => void; +}) => ( + +); + +const ApplicationFilterChips = ({ + regionFilter, + sortMode, + onRegionChange, + onSortModeChange, +}: { + regionFilter: RegionKo | ""; + sortMode: ScoreSort; + onRegionChange: (region: RegionKo | "") => void; + onSortModeChange: (sortMode: ScoreSort) => void; +}) => ( +
+ {REGIONS_KO.map((region) => ( + onRegionChange(regionFilter === region ? "" : region)} + > + {region} + + ))} + onSortModeChange(sortMode === "gpa" ? "applicants" : "gpa")} + > + 학점 높은 순 + +
+); + +const FilterChip = ({ + children, + isActive, + onClick, +}: { + children: ReactNode; + isActive: boolean; + onClick: () => void; +}) => ( + +); + +const ScoreSheetList = ({ scoreSheets }: { scoreSheets: ScoreSheetType[] }) => { + if (scoreSheets.length === 0) { + return ; + } + + return ( +
+ {scoreSheets.map((scoreSheet, index) => ( + + ))} +
+ ); +}; + +const ApplicationScoreEmptyState = () => ( +
+

조건에 맞는 대학이 없어요.

+

다른 필터로 다시 확인해 주세요.

+
+); + +const uniqueScoreSheets = (scoreSheets: ScoreSheetType[]) => { + const scoreSheetMap = new Map(); + + for (const scoreSheet of scoreSheets) { + const key = getScoreSheetKey(scoreSheet); + const prev = scoreSheetMap.get(key); + scoreSheetMap.set( + key, + prev ? { ...prev, applicants: mergeApplicants(prev.applicants, scoreSheet.applicants) } : scoreSheet, + ); + } + + return Array.from(scoreSheetMap.values()); +}; + +const getAppliedUniversities = (scoreChoices: ScoreSheetType[][], maxChoiceCount: number): AppliedUniversity[] => { + return scoreChoices.slice(0, maxChoiceCount).flatMap((choice, index) => { + const mine = choice.find((scoreSheet) => scoreSheet.applicants.some((applicant) => applicant.isMine)); + + return mine ? [{ preference: index + 1, scoreSheet: mine }] : []; + }); +}; + +const getScoreSheetKey = (scoreSheet: ScoreSheetType) => { + return scoreSheet.id ? String(scoreSheet.id) : scoreSheet.koreanName; +}; + +const mergeApplicants = (currentApplicants: Applicant[], nextApplicants: Applicant[]) => { + const applicantMap = new Map(); + + for (const applicant of currentApplicants) { + applicantMap.set(applicant.nicknameForApply, applicant); + } + + for (const applicant of nextApplicants) { + if (!applicantMap.has(applicant.nicknameForApply)) { + applicantMap.set(applicant.nicknameForApply, applicant); + } + } + + return Array.from(applicantMap.values()); +}; + +const getParticipantCount = (scoreSheets: ScoreSheetType[]) => { + const nicknames = new Set(); + + for (const scoreSheet of scoreSheets) { + for (const applicant of scoreSheet.applicants) { + nicknames.add(applicant.nicknameForApply); + } + } + + return nicknames.size; +}; + +const getAverageGpa = (scoreSheet: ScoreSheetType) => { + if (scoreSheet.applicants.length === 0) { + return 0; + } + + const totalGpa = scoreSheet.applicants.reduce((sum, applicant) => sum + applicant.gpa, 0); + return totalGpa / scoreSheet.applicants.length; +}; + +const sortScoreSheets = (scoreSheets: ScoreSheetType[], sortMode: ScoreSort) => { + return [...scoreSheets].sort((a, b) => { + if (sortMode === "gpa") { + return getAverageGpa(b) - getAverageGpa(a); + } + + return b.applicants.length - a.applicants.length; + }); +}; + export default ScorePageContent; diff --git a/apps/web/src/app/university/application/ScoreSheet.tsx b/apps/web/src/app/university/application/ScoreSheet.tsx index d568cdced..5593fd5a0 100644 --- a/apps/web/src/app/university/application/ScoreSheet.tsx +++ b/apps/web/src/app/university/application/ScoreSheet.tsx @@ -1,50 +1,113 @@ +"use client"; + +import clsx from "clsx"; +import Link from "next/link"; import { useState } from "react"; import { IconExpandMoreFilled } from "@/public/svgs/community"; -import type { ScoreSheet as ScoreSheetType } from "@/types/application"; +import type { Applicant, ScoreSheet as ScoreSheetType } from "@/types/application"; import { formatLanguageTestScore, isLanguageTestEnum, languageTestMapping } from "@/types/score"; -const ScoreSheet = ({ scoreSheet }: { scoreSheet: ScoreSheetType }) => { - const [tableOpened, setTableOpened] = useState(false); +type ScoreSheetProps = { + scoreSheet: ScoreSheetType; + defaultOpen?: boolean; + detailHref?: string; +}; + +export const getApplicationDetailHref = (koreanName: string) => + `/university/application/${encodeURIComponent(koreanName)}`; + +export const formatApplicantGpa = (gpa: number | null | undefined) => { + if (typeof gpa !== "number" || Number.isNaN(gpa)) { + return "-"; + } + + return gpa.toFixed(1); +}; + +export const formatApplicantLanguageTest = (applicant: Pick) => { + const testName = isLanguageTestEnum(applicant.testType) + ? languageTestMapping[applicant.testType] + : applicant.testType; + const score = isLanguageTestEnum(applicant.testType) + ? formatLanguageTestScore(applicant.testType, applicant.testScore) + : applicant.testScore; + + if (!testName || !score) { + return "-"; + } + + return `${testName} ${score}`; +}; + +export const formatApplicantLanguageTestName = (testType: string) => { + return isLanguageTestEnum(testType) ? languageTestMapping[testType] : testType; +}; + +export const formatApplicantLanguageScore = (applicant: Pick) => { + if (!applicant.testScore) { + return "-"; + } + + return isLanguageTestEnum(applicant.testType) + ? formatLanguageTestScore(applicant.testType, applicant.testScore) + : applicant.testScore; +}; + +const getScoreSheetCapacity = (scoreSheet: ScoreSheetType) => { + if (scoreSheet.studentCapacity === null || scoreSheet.studentCapacity === undefined) { + return "미정"; + } + + return String(scoreSheet.studentCapacity); +}; + +export const MobileScoreSheet = ({ scoreSheet, defaultOpen = false, detailHref }: ScoreSheetProps) => { + const [tableOpened, setTableOpened] = useState(defaultOpen); + const resolvedDetailHref = detailHref ?? getApplicationDetailHref(scoreSheet.koreanName); + const capacity = getScoreSheetCapacity(scoreSheet); return ( -
- + +
{tableOpened ? ( -
- {scoreSheet.applicants.map((applicant) => ( -
- - {applicant.nicknameForApply} - - - {applicant.gpa.toFixed(2)} - - - {isLanguageTestEnum(applicant.testType) ? languageTestMapping[applicant.testType] : applicant.testType} - - - {isLanguageTestEnum(applicant.testType) - ? formatLanguageTestScore(applicant.testType, applicant.testScore) - : applicant.testScore} - -
+
+ {scoreSheet.applicants.map((applicant, index) => ( + ))}
) : null} -
+ ); }; -export default ScoreSheet; +const ApplicantInlineRow = ({ applicant }: { applicant: Applicant }) => ( +
+ {applicant.nicknameForApply} + {formatApplicantGpa(applicant.gpa)} + {formatApplicantLanguageTestName(applicant.testType)} + {formatApplicantLanguageScore(applicant)} +
+); + +export default MobileScoreSheet; diff --git a/apps/web/src/app/university/application/[universityName]/ApplicationUniversityDetailContent.tsx b/apps/web/src/app/university/application/[universityName]/ApplicationUniversityDetailContent.tsx new file mode 100644 index 000000000..c25eb98eb --- /dev/null +++ b/apps/web/src/app/university/application/[universityName]/ApplicationUniversityDetailContent.tsx @@ -0,0 +1,290 @@ +"use client"; + +import { MobileHeroDetailShell } from "@solid-connect/ui"; +import clsx from "clsx"; +import Link from "next/link"; +import { type ReactNode, useMemo, useState } from "react"; +import { useGetApplicationsList } from "@/apis/applications"; +import Image from "@/components/ui/FallbackImage"; +import { showIconToast } from "@/lib/toast/showIconToast"; +import { IconShare } from "@/public/svgs"; +import type { Applicant, ScoreSheet as ScoreSheetType } from "@/types/application"; +import { normalizeImageUrlToUploadCdn } from "@/utils/cdnUrl"; +import { formatApplicantGpa, formatApplicantLanguageTest } from "../ScoreSheet"; + +type ApplicantSort = "preference" | "gpa"; + +type ApplicationUniversityDetailContentProps = { + universityName: string; +}; + +const ApplicationUniversityDetailContent = ({ universityName }: ApplicationUniversityDetailContentProps) => { + const [sortMode, setSortMode] = useState("preference"); + const { data, isLoading } = useGetApplicationsList(); + const scoreSheet = useMemo( + () => findScoreSheetWithApplicants(data?.choices ?? [], universityName), + [data?.choices, universityName], + ); + + const applicants = useMemo(() => { + if (!scoreSheet) return []; + + return [...scoreSheet.applicants].sort((a, b) => { + if (sortMode === "gpa") { + return b.gpa - a.gpa; + } + + return (a.preferenceOrder ?? 999) - (b.preferenceOrder ?? 999); + }); + }, [scoreSheet, sortMode]); + + if (isLoading) { + return ; + } + + if (!scoreSheet) { + return ; + } + + return ( +
+ +
+ ); +}; + +const MobileApplicationUniversityDetail = ({ + scoreSheet, + applicants, + sortMode, + setSortMode, +}: { + scoreSheet: ScoreSheetType; + applicants: Applicant[]; + sortMode: ApplicantSort; + setSortMode: (sortMode: ApplicantSort) => void; +}) => ( + } + background={ + {`${scoreSheet.koreanName} + } + logo={} + title={scoreSheet.koreanName} + subtitle={scoreSheet.englishName ?? ""} + stats={[ + { key: "country", content: getCountryLabel(scoreSheet.country) }, + { key: "capacity", content: getCapacityLabel(scoreSheet) }, + { key: "applicants", content: `지원자 ${scoreSheet.applicants.length}명` }, + ]} + > + + +); + +const ApplicantListSection = ({ + applicants, + sortMode, + setSortMode, +}: { + applicants: Applicant[]; + sortMode: ApplicantSort; + setSortMode: (sortMode: ApplicantSort) => void; +}) => ( +
+

지원자 목록 ({applicants.length}명)

+

모든 지원자들의 성적 정보를 확인하세요.

+
+ setSortMode("preference")}> + 지망 순위 순 + + setSortMode("gpa")}> + 학점 순 + +
+
+ {applicants.map((applicant, index) => ( + + ))} +
+
+); + +const ApplicantSortTab = ({ + children, + active, + onClick, +}: { + children: ReactNode; + active: boolean; + onClick: () => void; +}) => ( + +); + +const ApplicantScoreCard = ({ applicant }: { applicant: Applicant }) => ( +
+

{applicant.nicknameForApply}

+
+ + + + +
+
+); + +const ApplicantMetric = ({ label, value }: { label: string; value: string }) => ( +
+
{label}
+
{value}
+
+); + +const UniversityLogo = ({ scoreSheet }: { scoreSheet: ScoreSheetType }) => ( + 대학 로고 +); + +const ShareActionButton = () => { + const handleShare = async () => { + if (!navigator.clipboard?.writeText) { + showIconToast("logo", "링크 복사를 지원하지 않는 환경이에요."); + return; + } + + try { + await navigator.clipboard.writeText(window.location.href); + showIconToast("link", "URL이 복사되었습니다."); + } catch { + showIconToast("logo", "URL 복사에 실패했습니다."); + } + }; + + return ( + + ); +}; + +const ApplicationUniversityDetailSkeleton = () => ( +
+
+
+
+
+
+
+
+); + +const ApplicationUniversityDetailNotFound = ({ universityName }: { universityName: string }) => ( +
+

{universityName} 지원자 현황을 찾지 못했어요.

+ + 목록으로 돌아가기 + +
+); + +const findScoreSheetWithApplicants = ( + scoreChoices: ScoreSheetType[][], + universityName: string, +): ScoreSheetType | null => { + const matches = scoreChoices.flatMap((choice, choiceIndex) => + choice + .filter((scoreSheet) => scoreSheet.koreanName === universityName) + .map((scoreSheet) => ({ + preferenceOrder: choiceIndex + 1, + scoreSheet, + })), + ); + + if (matches.length === 0) { + return null; + } + + const baseScoreSheet = matches[0].scoreSheet; + const applicantMap = new Map(); + + for (const { preferenceOrder, scoreSheet } of matches) { + for (const applicant of scoreSheet.applicants) { + const key = applicant.nicknameForApply; + if (!applicantMap.has(key)) { + applicantMap.set(key, { + ...applicant, + preferenceOrder: applicant.preferenceOrder ?? preferenceOrder, + }); + } + } + } + + return { + ...baseScoreSheet, + applicants: Array.from(applicantMap.values()), + }; +}; + +const getApplicantConvertedScore = (applicant: Applicant) => { + const score = applicant.convertedScore ?? applicant.score; + + if (typeof score !== "number" || Number.isNaN(score)) { + return "-"; + } + + return String(score); +}; + +const getCapacityLabel = (scoreSheet: ScoreSheetType) => { + if (scoreSheet.studentCapacity === null || scoreSheet.studentCapacity === undefined) { + return "모집 미정"; + } + + return `모집 ${scoreSheet.studentCapacity}명`; +}; + +const countryFlagMap: Record = { + 미국: "🇺🇸", + 캐나다: "🇨🇦", + 일본: "🇯🇵", + 중국: "🇨🇳", + 대만: "🇹🇼", + 독일: "🇩🇪", + 프랑스: "🇫🇷", + 호주: "🇦🇺", +}; + +const getCountryLabel = (country: string) => { + const flag = countryFlagMap[country]; + return flag ? `${flag} ${country}` : country; +}; + +export default ApplicationUniversityDetailContent; diff --git a/apps/web/src/app/university/application/[universityName]/page.tsx b/apps/web/src/app/university/application/[universityName]/page.tsx new file mode 100644 index 000000000..3cc38b233 --- /dev/null +++ b/apps/web/src/app/university/application/[universityName]/page.tsx @@ -0,0 +1,47 @@ +import type { Metadata } from "next"; +import { notFound } from "next/navigation"; +import TopDetailNavigation from "@/components/layout/TopDetailNavigation"; +import { NO_INDEX_ROBOTS } from "@/utils/seo"; +import ApplicationUniversityDetailContent from "./ApplicationUniversityDetailContent"; + +type ApplicationUniversityDetailPageProps = { + params: Promise<{ universityName: string }>; +}; + +export const generateMetadata = async ({ params }: ApplicationUniversityDetailPageProps): Promise => { + const { universityName } = await params; + const decodedUniversityName = decodeUniversityNameParam(universityName); + + return { + title: decodedUniversityName ? `${decodedUniversityName} 지원자 현황` : "지원자 현황 확인", + robots: NO_INDEX_ROBOTS, + }; +}; + +const ApplicationUniversityDetailPage = async ({ params }: ApplicationUniversityDetailPageProps) => { + const { universityName } = await params; + const decodedUniversityName = decodeUniversityNameParam(universityName); + + if (!decodedUniversityName) { + notFound(); + } + + const title = `${decodedUniversityName} 지원자 현황`; + + return ( + <> + + + + ); +}; + +const decodeUniversityNameParam = (universityName: string) => { + try { + return decodeURIComponent(universityName); + } catch { + return null; + } +}; + +export default ApplicationUniversityDetailPage; diff --git a/apps/web/src/app/university/application/page.tsx b/apps/web/src/app/university/application/page.tsx index 142d4976a..e9d7a44d2 100644 --- a/apps/web/src/app/university/application/page.tsx +++ b/apps/web/src/app/university/application/page.tsx @@ -6,14 +6,14 @@ import { NO_INDEX_ROBOTS } from "@/utils/seo"; import ScorePageContent from "./ScorePageContent"; export const metadata: Metadata = { - title: "점수 공유 현황", + title: "지원자 현황 확인", robots: NO_INDEX_ROBOTS, }; const ScorePage = () => { return ( <> - +
diff --git a/apps/web/src/constants/university.ts b/apps/web/src/constants/university.ts index 1244df984..d4e37f4d3 100644 --- a/apps/web/src/constants/university.ts +++ b/apps/web/src/constants/university.ts @@ -4,12 +4,13 @@ import { type HomeUniversitySlug, LanguageTestType, RegionEnumExtend, + type RegionKo, type TestScoreInfo, } from "@/types/university"; export const REGIONS_SEARCH = ["유럽권", "미주권", "아시아권", "중국권"] as const; -export const REGIONS_KO = ["유럽권", "미주권", "아시아권", "중국권"]; +export const REGIONS_KO = ["유럽권", "미주권", "아시아권", "중국권"] as const satisfies readonly RegionKo[]; /** * 홈 대학교 URL 슬러그 매핑 diff --git a/apps/web/src/types/application.ts b/apps/web/src/types/application.ts index 5503e75a4..2c9c6fc92 100644 --- a/apps/web/src/types/application.ts +++ b/apps/web/src/types/application.ts @@ -13,11 +13,18 @@ export interface Applicant { testType: string; testScore: string; isMine: boolean; + score?: number | null; + convertedScore?: number | null; + preferenceOrder?: number | null; } export interface ScoreSheet { + id?: number; koreanName: string; - studentCapacity: number; + englishName?: string; + logoImageUrl?: string; + backgroundImageUrl?: string; + studentCapacity: number | null; region: string; country: string; applicants: Applicant[]; diff --git a/apps/web/src/types/university.ts b/apps/web/src/types/university.ts index fb3791d38..bf14111b0 100644 --- a/apps/web/src/types/university.ts +++ b/apps/web/src/types/university.ts @@ -1,4 +1,4 @@ -export type RegionKo = "유럽권" | "미주권" | "아시아권"; +export type RegionKo = "유럽권" | "미주권" | "아시아권" | "중국권"; export enum HomeUniversityName { INHA = "인하대학교", diff --git a/apps/web/tailwind.config.ts b/apps/web/tailwind.config.ts index db1f06170..f53483ff2 100644 --- a/apps/web/tailwind.config.ts +++ b/apps/web/tailwind.config.ts @@ -30,7 +30,7 @@ const typographyPlugin = plugin(({ addUtilities, theme }) => { const config: Config = { darkMode: ["class"], - content: ["./src/**/*.{js,ts,jsx,tsx,mdx}"], + content: ["./src/**/*.{js,ts,jsx,tsx,mdx}", "../../packages/ui/src/**/*.{js,ts,jsx,tsx,mdx}"], safelist: [ "ml-0", "ml-12", diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index c25627d50..5ed47cbed 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -1 +1,3 @@ export { cn } from "./cn"; +export type { MobileHeroDetailShellProps, MobileHeroDetailStat } from "./mobile-hero-detail-shell"; +export { MobileHeroDetailShell } from "./mobile-hero-detail-shell"; diff --git a/packages/ui/src/mobile-hero-detail-shell.tsx b/packages/ui/src/mobile-hero-detail-shell.tsx new file mode 100644 index 000000000..6e8581ff3 --- /dev/null +++ b/packages/ui/src/mobile-hero-detail-shell.tsx @@ -0,0 +1,66 @@ +import type { ReactNode } from "react"; + +export type MobileHeroDetailStat = { + key: string; + content: ReactNode; +}; + +export type MobileHeroDetailShellProps = { + background: ReactNode; + logo: ReactNode; + title: ReactNode; + subtitle?: ReactNode; + stats?: MobileHeroDetailStat[]; + leftAction?: ReactNode; + rightAction?: ReactNode; + children: ReactNode; +}; + +export const MobileHeroDetailShell = ({ + background, + logo, + title, + subtitle, + stats = [], + leftAction, + rightAction, + children, +}: MobileHeroDetailShellProps) => ( +
+
+
{background}
+
+ {leftAction || rightAction ? ( +
+
{leftAction}
+
{rightAction}
+
+ ) : null} +
+ +
+
+
+
{logo}
+
+

{title}

+ {subtitle ?

{subtitle}

: null} +
+
+ + {stats.length > 0 ? ( +
+ {stats.map((stat) => ( +
+ {stat.content} +
+ ))} +
+ ) : null} +
+ + {stats.length > 0 ?
: null} +
{children}
+
+
+); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 30f3c5981..6b2a5bb48 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -310,6 +310,9 @@ importers: '@solid-connect/ai-inspector': specifier: workspace:^ version: link:../../packages/ai-inspector + '@solid-connect/ui': + specifier: workspace:^ + version: link:../../packages/ui '@stomp/stompjs': specifier: ^7.1.1 version: 7.2.1