diff --git a/apps/web/src/apis/Scores/getGpaList.ts b/apps/web/src/apis/Scores/getGpaList.ts index e2e43eb5..75f16931 100644 --- a/apps/web/src/apis/Scores/getGpaList.ts +++ b/apps/web/src/apis/Scores/getGpaList.ts @@ -1,17 +1,24 @@ -import { useQuery } from "@tanstack/react-query"; +import { type UseQueryOptions, useQuery } from "@tanstack/react-query"; +import type { AxiosResponse } from "axios"; -import { ScoresQueryKeys, scoresApi } from "./api"; +import { ScoresQueryKeys, scoresApi, type UseMyGpaScoreResponse } from "./api"; + +type UseGetMyGpaScoreOptions = Omit< + UseQueryOptions, unknown, UseMyGpaScoreResponse>, + "queryKey" | "queryFn" +>; /** * @description 내 학점 점수 조회 훅 */ -const useGetMyGpaScore = ({ enabled = true }: { enabled?: boolean } = {}) => { +const useGetMyGpaScore = (props?: UseGetMyGpaScoreOptions) => { return useQuery({ queryKey: [ScoresQueryKeys.myGpaScore], queryFn: scoresApi.getMyGpaScore, - enabled, + enabled: true, staleTime: Infinity, select: (data) => data.data, + ...props, }); }; diff --git a/apps/web/src/apis/Scores/getLanguageTestList.ts b/apps/web/src/apis/Scores/getLanguageTestList.ts index 22a42549..3c170685 100644 --- a/apps/web/src/apis/Scores/getLanguageTestList.ts +++ b/apps/web/src/apis/Scores/getLanguageTestList.ts @@ -1,16 +1,25 @@ -import { useQuery } from "@tanstack/react-query"; +import { type UseQueryOptions, useQuery } from "@tanstack/react-query"; +import type { AxiosResponse } from "axios"; -import { ScoresQueryKeys, scoresApi } from "./api"; +import type { LanguageTestScore } from "@/types/score"; +import { ScoresQueryKeys, scoresApi, type UseGetMyLanguageTestScoreResponse } from "./api"; + +type UseGetMyLanguageTestScoreOptions = Omit< + UseQueryOptions, unknown, LanguageTestScore[]>, + "queryKey" | "queryFn" +>; /** * @description 내 어학 점수 조회 훅 */ -const useGetMyLanguageTestScore = () => { +const useGetMyLanguageTestScore = (props?: UseGetMyLanguageTestScoreOptions) => { return useQuery({ queryKey: [ScoresQueryKeys.myLanguageTestScore], queryFn: scoresApi.getMyLanguageTestScore, + enabled: true, staleTime: Infinity, select: (data) => data.data.languageTestScoreStatusResponseList, + ...props, }); }; diff --git a/apps/web/src/apis/applications/api.ts b/apps/web/src/apis/applications/api.ts index 111d02e6..9f10a94f 100644 --- a/apps/web/src/apis/applications/api.ts +++ b/apps/web/src/apis/applications/api.ts @@ -1,15 +1,20 @@ import type { AxiosResponse } from "axios"; -import type { ApplicationListResponse } from "@/types/application"; +import type { ApplicationListResponse, ApplicationPreviewResponse } from "@/types/application"; import { axiosInstance } from "@/utils/axiosInstance"; // ====== Query Keys ====== export const ApplicationsQueryKeys = { competitorsApplicationList: "competitorsApplicationList", + applicationPreview: "applicationPreview", } as const; // ====== Types ====== export interface UseSubmitApplicationResponse { - isSuccess: boolean; + totalApplyCount: number; + applyCount: number; + appliedUniversities: { + choices: string[]; + }; } export interface UseSubmitApplicationRequest { @@ -37,6 +42,13 @@ export const applicationsApi = { return axiosInstance.get("/applications"); }, + /** + * 지원자가 있는 대학의 제한 공개 정보 조회 + */ + getApplicationPreview: async (): Promise> => { + return axiosInstance.get("/applications/preview"); + }, + /** * 지원 제출 */ diff --git a/apps/web/src/apis/applications/getApplicationPreview.ts b/apps/web/src/apis/applications/getApplicationPreview.ts new file mode 100644 index 00000000..529eddac --- /dev/null +++ b/apps/web/src/apis/applications/getApplicationPreview.ts @@ -0,0 +1,17 @@ +import { useQuery } from "@tanstack/react-query"; +import type { AxiosError } from "axios"; +import type { ApplicationPreviewResponse } from "@/types/application"; +import { ApplicationsQueryKeys, applicationsApi } from "./api"; + +const useGetApplicationPreview = () => { + return useQuery>({ + queryKey: [ApplicationsQueryKeys.applicationPreview], + queryFn: async () => { + const response = await applicationsApi.getApplicationPreview(); + return response.data; + }, + staleTime: 1000 * 60 * 5, + }); +}; + +export default useGetApplicationPreview; diff --git a/apps/web/src/apis/applications/index.ts b/apps/web/src/apis/applications/index.ts index f0b0f16b..bd18bf6b 100644 --- a/apps/web/src/apis/applications/index.ts +++ b/apps/web/src/apis/applications/index.ts @@ -1,5 +1,6 @@ export type { UseSubmitApplicationRequest, UseSubmitApplicationResponse } from "./api"; export { ApplicationsQueryKeys, applicationsApi } from "./api"; export { default as useGetApplicationsList } from "./getApplicants"; +export { default as useGetApplicationPreview } from "./getApplicationPreview"; export { default as useGetCompetitors } from "./getCompetitors"; export { default as usePostSubmitApplication } from "./postSubmitApplication"; diff --git a/apps/web/src/app/my/school-email/_lib/returnTo.ts b/apps/web/src/app/my/school-email/_lib/returnTo.ts index 989c76a3..0afc4934 100644 --- a/apps/web/src/app/my/school-email/_lib/returnTo.ts +++ b/apps/web/src/app/my/school-email/_lib/returnTo.ts @@ -1,5 +1,6 @@ export const SCHOOL_EMAIL_RETURN_PATHS = { applicationApply: "/university/application/apply", + applicationStatus: "/university/application", score: "/university/score", gpaSubmit: "/university/score/submit/gpa", } as const; diff --git a/apps/web/src/app/university/application/ScorePageContent.tsx b/apps/web/src/app/university/application/ScorePageContent.tsx index 216c38e2..6e364caa 100644 --- a/apps/web/src/app/university/application/ScorePageContent.tsx +++ b/apps/web/src/app/university/application/ScorePageContent.tsx @@ -1,420 +1,93 @@ "use client"; -import clsx from "clsx"; -import Link from "next/link"; import { useRouter } from "next/navigation"; -import { type ReactNode, useEffect, useMemo, useState } from "react"; -import { useGetApplicationsList } from "@/apis/applications"; -import ConfirmCancelModal from "@/components/modal/ConfirmCancelModal"; -import { DEFAULT_MAX_CHOICE_COUNT, getHomeUniversityById, REGIONS_KO } from "@/constants/university"; +import { useEffect } from "react"; +import { useGetMyGpaScore, useGetMyLanguageTestScore } from "@/apis/Scores"; +import { getSchoolEmailVerificationPath } from "@/app/my/school-email/_lib/returnTo"; +import CloudSpinnerPage from "@/components/ui/CloudSpinnerPage"; import useAuthStore from "@/lib/zustand/useAuthStore"; -import { IconExpandMoreFilled } from "@/public/svgs/community"; -import type { Applicant, ScoreSheet as ScoreSheetType } from "@/types/application"; -import type { RegionKo } from "@/types/university"; -import { getApplicationDetailHref, MobileScoreSheet, ScoreSheetLogo } 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; -}; +import ApprovedApplicationStatusPage from "./_pages/ApprovedApplicationStatusPage"; +import { resolveApplicationStatusAccess } from "./_pages/applicationStatusAccess"; +import GuestApplicationStatusPage from "./_pages/GuestApplicationStatusPage"; +import ScorePendingApplicationStatusPage from "./_pages/ScorePendingApplicationStatusPage"; +import SignedInApplicationStatusPage from "./_pages/SignedInApplicationStatusPage"; const ScorePageContent = () => { const router = useRouter(); + const isAuthInitialized = useAuthStore((state) => state.isInitialized); + const isAuthenticated = useAuthStore((state) => state.isAuthenticated); const homeUniversityId = useAuthStore((state) => state.homeUniversityId); - const maxChoiceCount = getHomeUniversityById(homeUniversityId)?.maxChoiceCount ?? DEFAULT_MAX_CHOICE_COUNT; - - const [scope, setScope] = useState("withApplicants"); - const [regionFilter, setRegionFilter] = useState(""); - const [sortMode, setSortMode] = useState("applicants"); - const [showNeedApply, _setShowNeedApply] = useState(false); - - const emptyChoices = useMemo( - () => Array.from({ length: maxChoiceCount }, () => [] as ScoreSheetType[]), - [maxChoiceCount], - ); - const { data: scoreResponseData, isError, isLoading } = useGetApplicationsList(); - const scoreChoices = scoreResponseData?.choices ?? emptyChoices; - - 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]); - - const displayedScoreSheets = useMemo(() => { - let result = - scope === "withApplicants" - ? allScoreSheets.filter((scoreSheet) => scoreSheet.applicants.length > 0) - : allScoreSheets; - - if (regionFilter) { - result = result.filter((scoreSheet) => scoreSheet.region === regionFilter); - } - - return sortScoreSheets(result, sortMode); - }, [allScoreSheets, regionFilter, scope, sortMode]); - - useEffect(() => { - if (isLoading) return; - if (isError) { - router.replace("/university/application/apply"); - } - }, [isError, isLoading, router]); - - 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="학교 지원하기" - /> - - ); -}; - -const ApplicationScoreView = ({ - appliedUniversities, - displayedScoreSheets, - totalUniversityCount, - applicantUniversityCount, - participantCount, - scope, - regionFilter, - sortMode, - onScopeChange, - onRegionChange, - onSortModeChange, - onChangeUniversities, -}: ScorePageViewProps) => { - return ( -
- -
- - - - -
- ); -}; - -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; + const needsSchoolEmailVerification = isAuthInitialized && isAuthenticated && homeUniversityId === null; + const shouldFetchScoreStatus = isAuthInitialized && isAuthenticated && homeUniversityId !== null; + + const { + data: gpaScoreData, + isLoading: isGpaLoading, + isError: isGpaError, + refetch: refetchGpa, + } = useGetMyGpaScore({ enabled: shouldFetchScoreStatus, refetchOnMount: "always" }); + const { + data: languageTestScores = [], + isLoading: isLanguageTestLoading, + isError: isLanguageTestError, + refetch: refetchLanguageTest, + } = useGetMyLanguageTestScore({ + enabled: shouldFetchScoreStatus, + refetchOnMount: "always", + }); - return ( - - - - {scoreSheet.koreanName} ({preference}/{capacity}) - - - - - + useEffect( + function redirectToSchoolEmailVerification() { + if (!needsSchoolEmailVerification) return; + router.replace(getSchoolEmailVerificationPath("applicationStatus")); + }, + [needsSchoolEmailVerification, router], ); -}; - -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 ; + if ( + !isAuthInitialized || + needsSchoolEmailVerification || + (shouldFetchScoreStatus && (isGpaLoading || isLanguageTestLoading)) + ) { + 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, - englishName: prev.englishName ?? scoreSheet.englishName, - logoImageUrl: prev.logoImageUrl ?? scoreSheet.logoImageUrl, - backgroundImageUrl: prev.backgroundImageUrl ?? scoreSheet.backgroundImageUrl, - applicants: mergeApplicants(prev.applicants, scoreSheet.applicants), - } - : scoreSheet, + if (shouldFetchScoreStatus && (isGpaError || isLanguageTestError)) { + return ( +
+

성적 승인 상태를 불러오지 못했어요.

+ +
); } - 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 access = resolveApplicationStatusAccess({ + isAuthenticated, + homeUniversityId, + gpaScores: gpaScoreData?.gpaScoreStatusResponseList ?? [], + languageTestScores, }); -}; - -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); + switch (access) { + case "guest": + return ; + case "signedIn": + return ; + case "scorePending": + return ; + case "approved": + return ; + default: { + const exhaustiveAccess: never = access; + return exhaustiveAccess; } } - - 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/_components/RestrictedApplicationStatusView.tsx b/apps/web/src/app/university/application/_components/RestrictedApplicationStatusView.tsx new file mode 100644 index 00000000..68087e3f --- /dev/null +++ b/apps/web/src/app/university/application/_components/RestrictedApplicationStatusView.tsx @@ -0,0 +1,171 @@ +"use client"; + +import clsx from "clsx"; +import type { ReactNode } from "react"; +import Image from "@/components/ui/FallbackImage"; +import { REGIONS_KO } from "@/constants/university"; +import { IconExpandMoreFilled } from "@/public/svgs/community"; +import type { ApplicationUniversityPreview } from "@/types/application"; + +const PREVIEW_ROW_COUNT = 8; + +type RestrictedApplicationStatusViewProps = { + totalUniversityCount?: number; + universities?: ApplicationUniversityPreview[]; + isLoading?: boolean; + showPlaceholderRows?: boolean; + guestOverlay?: ReactNode; + onLockedRowClick?: () => void; +}; + +const RestrictedApplicationStatusView = ({ + totalUniversityCount = 0, + universities = [], + isLoading = false, + showPlaceholderRows = false, + guestOverlay, + onLockedRowClick, +}: RestrictedApplicationStatusViewProps) => { + const previewUniversities = universities.slice(0, PREVIEW_ROW_COUNT); + const shouldShowSkeleton = isLoading || showPlaceholderRows; + + return ( +
+ + + +
+ {shouldShowSkeleton ? ( + Array.from({ length: PREVIEW_ROW_COUNT }, (_, index) => ) + ) : previewUniversities.length > 0 ? ( + previewUniversities.map((university) => ( + + )) + ) : ( + + )} +
+ {guestOverlay} +
+ ); +}; + +const RestrictedParticipantBanner = () => ( +
+
+
+ 🔥 +
+
+

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

+

+ 총 + 명이 성적 공유 참여중! +

+
+
+
+); + +const RestrictedScopeTabs = ({ totalUniversityCount }: { totalUniversityCount: number }) => ( +
+ 모든 대학 ({totalUniversityCount || "-"}) + + 지원자 있는 대학 () + +
+); + +const RestrictedScopeTab = ({ children, active = false }: { children: ReactNode; active?: boolean }) => ( +
+ {children} +
+); + +const RestrictedFilterChips = () => ( +
+ {REGIONS_KO.map((region) => ( + + {region} + + ))} + 학점 높은 순 +
+); + +const RestrictedUniversityRow = ({ + university, + onClick, +}: { + university: ApplicationUniversityPreview; + onClick?: () => void; +}) => { + const content = ( + <> + + + {university.koreanName} + + + + ); + + if (onClick) { + return ( + + ); + } + + return
{content}
; +}; + +const RestrictedSkeletonRow = () => ( +
+ + + +
+); + +const RestrictedUniversityEmptyState = () => ( +
+

아직 지원자가 있는 대학이 없어요.

+

지원 현황이 등록되면 여기에 표시됩니다.

+
+); + +const MaskedValue = ({ label, wide = false }: { label: string; wide?: boolean }) => ( + <> + + ••• + + {label} + +); + +export default RestrictedApplicationStatusView; diff --git a/apps/web/src/app/university/application/_pages/ApprovedApplicationStatusPage.tsx b/apps/web/src/app/university/application/_pages/ApprovedApplicationStatusPage.tsx new file mode 100644 index 00000000..ff2f466f --- /dev/null +++ b/apps/web/src/app/university/application/_pages/ApprovedApplicationStatusPage.tsx @@ -0,0 +1,453 @@ +"use client"; + +import type { AxiosError } from "axios"; +import clsx from "clsx"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { type ReactNode, useEffect, useMemo, useState } from "react"; +import { useGetApplicationsList } from "@/apis/applications"; +import CloudSpinnerPage from "@/components/ui/CloudSpinnerPage"; +import { DEFAULT_MAX_CHOICE_COUNT, getHomeUniversityById, REGIONS_KO } from "@/constants/university"; +import { SKIP_GLOBAL_ERROR_TOAST_META } from "@/lib/react-query/errorToastMeta"; +import useAuthStore from "@/lib/zustand/useAuthStore"; +import { IconExpandMoreFilled } from "@/public/svgs/community"; +import type { Applicant, ScoreSheet as ScoreSheetType } from "@/types/application"; +import type { RegionKo } from "@/types/university"; +import { getApplicationDetailHref, MobileScoreSheet, ScoreSheetLogo } from "../ScoreSheet"; + +type ApplicationAccessErrorCode = "APPLICATION_NOT_FOUND" | "APPLICATION_NOT_APPROVED"; + +const APPLICATION_ACCESS_ERRORS: Record = { + APPLICATION_NOT_FOUND: { status: 404, message: "사용자의 대학 지원 정보를 찾을 수 없습니다." }, + APPLICATION_NOT_APPROVED: { status: 400, message: "성적표가 인증되지 않았습니다." }, +}; + +const isApplicationAccessError = (error: AxiosError<{ message: string }> | null): boolean => { + if (!error) return false; + + const status = error.response?.status; + const message = error.response?.data?.message; + + return Object.values(APPLICATION_ACCESS_ERRORS).some( + (accessError) => accessError.status === status && accessError.message === message, + ); +}; + +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 ApprovedApplicationStatusPage = () => { + const router = useRouter(); + const homeUniversityId = useAuthStore((state) => state.homeUniversityId); + const maxChoiceCount = getHomeUniversityById(homeUniversityId)?.maxChoiceCount ?? DEFAULT_MAX_CHOICE_COUNT; + + const [scope, setScope] = useState("withApplicants"); + const [regionFilter, setRegionFilter] = useState(""); + const [sortMode, setSortMode] = useState("applicants"); + const emptyChoices = useMemo( + () => Array.from({ length: maxChoiceCount }, () => [] as ScoreSheetType[]), + [maxChoiceCount], + ); + const { + data: scoreResponseData, + isError, + isLoading, + error, + refetch, + } = useGetApplicationsList({ + meta: SKIP_GLOBAL_ERROR_TOAST_META, + }); + const scoreChoices = scoreResponseData?.choices ?? emptyChoices; + const isApplicationMissingOrUnapproved = isApplicationAccessError(error); + + const allScoreSheets = useMemo(() => uniqueScoreSheets(scoreChoices.flat()), [scoreChoices]); + 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]); + + const displayedScoreSheets = useMemo(() => { + let result = + scope === "withApplicants" + ? allScoreSheets.filter((scoreSheet) => scoreSheet.applicants.length > 0) + : allScoreSheets; + + if (regionFilter) { + result = result.filter((scoreSheet) => scoreSheet.region === regionFilter); + } + + return sortScoreSheets(result, sortMode); + }, [allScoreSheets, regionFilter, scope, sortMode]); + + useEffect( + function redirectToApplyWhenApplicationMissing() { + if (isLoading) return; + if (isError && isApplicationMissingOrUnapproved) { + router.replace("/university/application/apply"); + } + }, + [isApplicationMissingOrUnapproved, isError, isLoading, router], + ); + + if (isLoading || (isError && isApplicationMissingOrUnapproved)) { + return ; + } + + if (isError) { + return ( +
+

지원 현황을 불러오지 못했어요.

+ +
+ ); + } + + const viewProps = { + appliedUniversities, + displayedScoreSheets, + totalUniversityCount, + applicantUniversityCount, + participantCount, + scope, + regionFilter, + sortMode, + onScopeChange: setScope, + onRegionChange: setRegionFilter, + onSortModeChange: setSortMode, + onChangeUniversities: () => router.push("/university/application/apply"), + }; + + return ; +}; + +const ApplicationScoreView = ({ + appliedUniversities, + displayedScoreSheets, + totalUniversityCount, + applicantUniversityCount, + participantCount, + scope, + regionFilter, + sortMode, + onScopeChange, + onRegionChange, + onSortModeChange, + onChangeUniversities, +}: ScorePageViewProps) => { + return ( +
+ +
+ + + + +
+ ); +}; + +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, + englishName: prev.englishName ?? scoreSheet.englishName, + logoImageUrl: prev.logoImageUrl ?? scoreSheet.logoImageUrl, + backgroundImageUrl: prev.backgroundImageUrl ?? scoreSheet.backgroundImageUrl, + 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 ApprovedApplicationStatusPage; diff --git a/apps/web/src/app/university/application/_pages/GuestApplicationStatusPage.tsx b/apps/web/src/app/university/application/_pages/GuestApplicationStatusPage.tsx new file mode 100644 index 00000000..605a4d47 --- /dev/null +++ b/apps/web/src/app/university/application/_pages/GuestApplicationStatusPage.tsx @@ -0,0 +1,25 @@ +"use client"; + +import Link from "next/link"; +import RestrictedApplicationStatusView from "../_components/RestrictedApplicationStatusView"; + +const GuestApplicationStatusPage = () => ( + +
+

로그인하고 지금 바로 경쟁률을 확인하세요.

+ + 로그인하러가기 + +
+ + } + /> +); + +export default GuestApplicationStatusPage; diff --git a/apps/web/src/app/university/application/_pages/ScorePendingApplicationStatusPage.tsx b/apps/web/src/app/university/application/_pages/ScorePendingApplicationStatusPage.tsx new file mode 100644 index 00000000..a5ac68ff --- /dev/null +++ b/apps/web/src/app/university/application/_pages/ScorePendingApplicationStatusPage.tsx @@ -0,0 +1,27 @@ +"use client"; + +import { useEffect } from "react"; +import { useGetApplicationPreview } from "@/apis/applications"; +import { showIconToast } from "@/lib/toast/showIconToast"; +import RestrictedApplicationStatusView from "../_components/RestrictedApplicationStatusView"; + +const SCORE_PENDING_MESSAGE = "성적 승인이 아직 안되었어요! 승인 완료 후에 지원자 수 확인이 가능합니다"; + +const ScorePendingApplicationStatusPage = () => { + const { data, isLoading } = useGetApplicationPreview(); + + useEffect(() => { + showIconToast("cap", SCORE_PENDING_MESSAGE); + }, []); + + return ( + showIconToast("cap", SCORE_PENDING_MESSAGE)} + /> + ); +}; + +export default ScorePendingApplicationStatusPage; diff --git a/apps/web/src/app/university/application/_pages/SignedInApplicationStatusPage.tsx b/apps/web/src/app/university/application/_pages/SignedInApplicationStatusPage.tsx new file mode 100644 index 00000000..2a0693cb --- /dev/null +++ b/apps/web/src/app/university/application/_pages/SignedInApplicationStatusPage.tsx @@ -0,0 +1,18 @@ +"use client"; + +import { useGetApplicationPreview } from "@/apis/applications"; +import RestrictedApplicationStatusView from "../_components/RestrictedApplicationStatusView"; + +const SignedInApplicationStatusPage = () => { + const { data, isLoading } = useGetApplicationPreview(); + + return ( + + ); +}; + +export default SignedInApplicationStatusPage; diff --git a/apps/web/src/app/university/application/_pages/applicationStatusAccess.ts b/apps/web/src/app/university/application/_pages/applicationStatusAccess.ts new file mode 100644 index 00000000..a40c6e64 --- /dev/null +++ b/apps/web/src/app/university/application/_pages/applicationStatusAccess.ts @@ -0,0 +1,34 @@ +import { type GpaScore, type LanguageTestScore, ScoreSubmitStatus } from "@/types/score"; + +export type ApplicationStatusAccess = "guest" | "signedIn" | "scorePending" | "approved"; + +type ResolveApplicationStatusAccessParams = { + isAuthenticated: boolean; + homeUniversityId: number | null; + gpaScores: GpaScore[]; + languageTestScores: LanguageTestScore[]; +}; + +export const resolveApplicationStatusAccess = ({ + isAuthenticated, + homeUniversityId, + gpaScores, + languageTestScores, +}: ResolveApplicationStatusAccessParams): ApplicationStatusAccess => { + if (!isAuthenticated) { + return "guest"; + } + + if (homeUniversityId === null || gpaScores.length === 0 || languageTestScores.length === 0) { + return "signedIn"; + } + + const hasApprovedGpa = gpaScores.some((score) => score.verifyStatus === ScoreSubmitStatus.APPROVED); + const hasApprovedLanguageTest = languageTestScores.some((score) => score.verifyStatus === ScoreSubmitStatus.APPROVED); + + if (!hasApprovedGpa || !hasApprovedLanguageTest) { + return "scorePending"; + } + + return "approved"; +}; diff --git a/apps/web/src/types/application.ts b/apps/web/src/types/application.ts index 53f0e0e9..f55a38bc 100644 --- a/apps/web/src/types/application.ts +++ b/apps/web/src/types/application.ts @@ -59,6 +59,21 @@ export interface ApplicationListResponse { choices: ScoreSheet[][]; } +export interface ApplicationUniversityPreview { + id: number; + koreanName: string; + studentCapacity: number | null; + region: string; + country: string; + logoImageUrl: string; + backgroundImageUrl: string; +} + +export interface ApplicationPreviewResponse { + totalUniversityCount: number; + universities: ApplicationUniversityPreview[]; +} + export interface ApplicationStatusResponse { status: ApplyStatus; updateCount: number; diff --git a/docs/screenshots/application-status/approved.png b/docs/screenshots/application-status/approved.png new file mode 100644 index 00000000..f9948126 Binary files /dev/null and b/docs/screenshots/application-status/approved.png differ diff --git a/docs/screenshots/application-status/as-is-guest.jpg b/docs/screenshots/application-status/as-is-guest.jpg new file mode 100644 index 00000000..abb4a579 Binary files /dev/null and b/docs/screenshots/application-status/as-is-guest.jpg differ diff --git a/docs/screenshots/application-status/guest.png b/docs/screenshots/application-status/guest.png new file mode 100644 index 00000000..04deb704 Binary files /dev/null and b/docs/screenshots/application-status/guest.png differ diff --git a/docs/screenshots/application-status/score-pending.png b/docs/screenshots/application-status/score-pending.png new file mode 100644 index 00000000..0891ea2a Binary files /dev/null and b/docs/screenshots/application-status/score-pending.png differ diff --git a/docs/screenshots/application-status/signed-in-score-unsubmitted.png b/docs/screenshots/application-status/signed-in-score-unsubmitted.png new file mode 100644 index 00000000..aef89b31 Binary files /dev/null and b/docs/screenshots/application-status/signed-in-score-unsubmitted.png differ diff --git a/docs/screenshots/application-status/to-be-guest.jpg b/docs/screenshots/application-status/to-be-guest.jpg new file mode 100644 index 00000000..67a9460a Binary files /dev/null and b/docs/screenshots/application-status/to-be-guest.jpg differ