Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: timetable selection 페이지 에러 처리 #82

Open
wants to merge 6 commits into
base: master
Choose a base branch
from
Open
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
9 changes: 4 additions & 5 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,9 +31,8 @@
"lucide-react": "^0.473.0",
"mixpanel-browser": "^2.60.0",
"motion": "^12.0.6",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-error-boundary": "^5.0.0",
"react": "^18.3.0",
"react-dom": "^18.3.0",
"react-use-measure": "^2.1.7",
"tailwind-merge": "^3.0.1",
"tailwindcss": "^4.0.0",
Expand All @@ -44,8 +43,8 @@
"@eslint/js": "^9.17.0",
"@types/lodash": "^4.17.15",
"@types/mixpanel-browser": "^2.51.0",
"@types/react": "^18.3.18",
"@types/react-dom": "^18.3.5",
"@types/react": "^18.3.0",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.4",
"eslint": "^9.17.0",
"eslint-config-prettier": "^10.0.1",
Expand Down
21 changes: 4 additions & 17 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion src/components/CourseListItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ const CourseListItem = ({ onClickCourseItem, course, isSelected }: CourseListIte
<div className="text-[20px] font-semibold">{course.courseName}</div>
<div className="text-[12px] font-light">{course.professorName}</div>
</div>
{course.credit > 0 && (
{course.courseName.length !== 0 && (
<div className="flex h-6 items-center rounded-lg bg-[#ECEFFF] px-1.5 text-[12px]/[18px] font-semibold text-nowrap text-[#5736F5]">
{course.credit}학점
</div>
Expand Down
4 changes: 2 additions & 2 deletions src/components/CourseSelection/CourseSelectionFallback.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import CourseSelectionView from './CourseSelectionView.tsx';
import { emptyCourse } from '../../data/courseSelectionInfo.ts';
import { emptyCourses } from '../../data/courseSelectionInfo.ts';
import Warning from '../../assets/warning.svg';

type CourseSelectionFallbackType = 'pending' | 'error';
Expand Down Expand Up @@ -32,7 +32,7 @@ interface CourseSelectionFallbackProps {
const CourseSelectionFallback = ({ type }: CourseSelectionFallbackProps) => {
return (
<CourseSelectionView
courses={[emptyCourse, emptyCourse, emptyCourse]}
courses={emptyCourses}
resultState={'FILLED'}
selectedCourses={[]}
selectedGrades={[]}
Expand Down
2 changes: 1 addition & 1 deletion src/components/CourseSelection/CourseSelectionView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ const CourseSelectionView = ({
isSelected={selectedCourses.some((selectedCourse) =>
isSameCourse(course, selectedCourse),
)}
key={course.courseName}
key={`${course.courseName} ${course.credit}`}
course={course}
/>
))}
Expand Down
278 changes: 278 additions & 0 deletions src/components/DesireCredit.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,278 @@
import RollingNumber from './RollingNumber.tsx';
import * as Popover from '@radix-ui/react-popover';
import { Check, ChevronDown } from 'lucide-react';
import { AnimatePresence, motion } from 'motion/react';
import Hint from './Hint.tsx';
import { useState } from 'react';
import { StudentMachineContext } from '../machines/studentMachine.ts';
import { usePostTimetable } from '../hooks/usePostTimetable.ts';
import { Mixpanel } from '../utils/mixpanel.ts';
import { DesiredCreditParams } from '../pages/DesiredCreditActivity.tsx';
import { useFlow } from '../stackflow.ts';

const getAvailableCredits = (currentCredit: number, baseCredit: number = 0): number[] => {
return Array.from({ length: MAX_CREDIT - currentCredit + 1 }, (_, i) => i + baseCredit);
};

type Classification = '전공필수' | '전공선택' | '교양필수' | '교양선택';

const MAX_CREDIT = 22;

const DesireCredit = (params: DesiredCreditParams) => {
const previousCredit = params.majorRequired + params.majorElective + params.generalRequired; // 과목 선택 페이지에서 선택한 전필 + 전선 + 교필 학점
const [desiredCredit, setDesiredCredit] = useState(previousCredit); // 희망 학점
const { push } = useFlow();

const [availableMajorElective, setAvailableMajorElective] = useState(() =>
getAvailableCredits(previousCredit, params.majorElective),
); // 수강 가능한 전공선택 학점
const [availableGeneralElective, setAvailableGeneralElective] = useState(() =>
getAvailableCredits(previousCredit),
); // 수강 가능한 교양선택 학점

const [majorElective, setMajorElective] = useState(params.majorElective); // 전공선택 학점
const [generalElective, setGeneralElective] = useState(0); // 교양선택 학점

const [showMajorElectiveDropdown, setShowMajorElectiveDropdown] = useState(false);
const [showGeneralElectiveDropdown, setShowGeneralElectiveDropdown] = useState(false);

const context = StudentMachineContext.useSelector((state) => state.context);
const postTimetableMutation = usePostTimetable();

const handleCreditSelect = ({
type,
selectedCredit,
}: {
type: Classification;
selectedCredit: number;
}) => {
if (type === '전공선택') {
const majorElectiveDiff = selectedCredit - majorElective;

setMajorElective(selectedCredit);
setDesiredCredit((prev) => prev + majorElectiveDiff);
setAvailableGeneralElective(
getAvailableCredits(desiredCredit + majorElectiveDiff - generalElective),
);

setShowMajorElectiveDropdown(false);
} else if (type === '교양선택') {
const generalElectiveDiff = selectedCredit - generalElective;
const majorElectiveDiff = majorElective - params.majorElective;

setGeneralElective(selectedCredit);
setDesiredCredit((prev) => prev + generalElectiveDiff);
setAvailableMajorElective(
getAvailableCredits(
desiredCredit + generalElectiveDiff - majorElectiveDiff,
params.majorElective,
),
);

setShowGeneralElectiveDropdown(false);
}
};

const handleNextClick = () => {
// 시간표 추천 API 요청
postTimetableMutation.mutate({
schoolId: context.admissionYear,
department: context.department,
grade: context.grade,
isChapel: context.chapel,
majorRequiredCourses: params.majorRequiredCourses,
majorElectiveCourses: params.majorElectiveCourses,
generalRequiredCourses: params.generalRequiredCourses,
majorElectiveCredit: majorElective,
generalElectiveCredit: generalElective,
});

// Mixpanel 이벤트 추적
Mixpanel.trackDesiredCreditClick({
majorRequiredCourses: params.majorRequiredCourses,
majorElectiveCourses: params.majorElectiveCourses,
generalRequiredCourses: params.generalRequiredCourses,
majorElectiveCredit: majorElective,
generalElectiveCredit: generalElective,
});

push('TimetableSelectionActivity', {});
};

return (
<div className="mt-6 flex flex-1 flex-col items-center">
<h2 className="text-center text-[28px] font-semibold">
사용자님의 이번학기 <br />
희망 학점은 <RollingNumber number={desiredCredit} className="text-primary" />
학점이군요!
</h2>
<span className="mt-1 font-light">희망 학점에 맞추어 선택과목을 추천해드릴게요.</span>
<div className="mt-6 grid grid-cols-2 gap-x-2.5 gap-y-6 px-12">
<div>
<label className="mb-1.5 block text-sm">전공필수 학점</label>
<input
type="number"
disabled
value={params.majorRequired}
className="bg-basic-light text-primary w-full rounded-xl px-4 py-3 text-lg font-semibold"
/>
</div>

<div>
<label className="mb-1.5 block text-sm">전공선택 학점</label>
<Popover.Root
open={showMajorElectiveDropdown}
onOpenChange={setShowMajorElectiveDropdown}
>
<Popover.Trigger asChild>
<button
type="button"
className={`bg-basic-light focus-visible:outline-ring flex w-full items-center justify-between rounded-xl px-4 py-3 text-lg font-semibold ${majorElective === params.majorElective ? 'text-placeholder' : 'text-primary'}`}
>
{majorElective}
<ChevronDown className="text-text size-4" />
</button>
</Popover.Trigger>

<AnimatePresence>
{showMajorElectiveDropdown && (
<Popover.Content asChild sideOffset={5} forceMount>
<motion.ul
className="bg-basic-light z-10 max-h-44 w-[var(--radix-popover-trigger-width)] overflow-y-auto rounded-xl border border-gray-200 shadow-sm"
initial={{ opacity: 0, y: -10 }}
animate={{
opacity: 1,
y: 0,
}}
exit={{
opacity: 0,
y: -10,
}}
transition={{
duration: 0.2,
}}
>
{availableMajorElective.map((availableCredit) => (
<li key={availableCredit}>
<button
type="button"
className="text-list focus-visible:outline-ring flex w-full items-center justify-between rounded-xl px-4 py-2 text-lg font-semibold hover:bg-gray-100 focus-visible:-outline-offset-1"
onClick={() =>
handleCreditSelect({
type: '전공선택',
selectedCredit: availableCredit,
})
}
>
{availableCredit}
{availableCredit === majorElective && (
<Check className="size-4 text-green-500" />
)}
</button>
</li>
))}
</motion.ul>
</Popover.Content>
)}
</AnimatePresence>
</Popover.Root>
</div>

<div>
<label className="mb-1.5 block text-sm">교양필수 학점</label>
<input
type="number"
disabled
value={params.generalRequired}
className="bg-basic-light text-primary w-full rounded-xl px-4 py-3 text-lg font-semibold"
/>
</div>

<div>
<label className="mb-1.5 block text-sm">교양선택 학점</label>

<Popover.Root
open={showGeneralElectiveDropdown}
onOpenChange={setShowGeneralElectiveDropdown}
>
<Popover.Trigger asChild>
<button
type="button"
className={`bg-basic-light focus-visible:outline-ring flex w-full items-center justify-between rounded-xl px-4 py-3 text-lg font-semibold ${generalElective === 0 ? 'text-placeholder' : 'text-primary'}`}
>
{generalElective}
<ChevronDown className="text-text size-4" />
</button>
</Popover.Trigger>

<AnimatePresence>
{showGeneralElectiveDropdown && (
<Popover.Content asChild sideOffset={5} forceMount>
<motion.ul
className="bg-basic-light z-10 max-h-44 w-[var(--radix-popover-trigger-width)] overflow-y-auto rounded-xl border border-gray-200 shadow-sm"
initial={{ opacity: 0, y: -10 }}
animate={{
opacity: 1,
y: 0,
}}
exit={{
opacity: 0,
y: -10,
}}
transition={{
duration: 0.2,
}}
>
{availableGeneralElective.map((availableCredit) => (
<li key={availableCredit}>
<button
type="button"
className="text-list focus-visible:outline-ring flex w-full items-center justify-between rounded-xl px-4 py-2 text-lg font-semibold hover:bg-gray-100 focus-visible:-outline-offset-1"
onClick={() =>
handleCreditSelect({
type: '교양선택',
selectedCredit: availableCredit,
})
}
>
{availableCredit}
{availableCredit === generalElective && (
<Check className="size-4 text-green-500" />
)}
</button>
</li>
))}
</motion.ul>
</Popover.Content>
)}
</AnimatePresence>
</Popover.Root>
</div>
</div>

<Hint className="mt-2 self-start px-12">
<Hint.Icon />
<Hint.Text>이수 가능한 최대 학점은 22학점이에요.</Hint.Text>
</Hint>

<motion.button
onClick={handleNextClick}
type="button"
className="bg-primary mt-auto w-50 rounded-2xl py-3.5 font-semibold text-white"
initial={{
opacity: 0,
y: 20,
}}
animate={{ opacity: 1, y: 0 }}
transition={{
duration: 0.3,
ease: 'easeOut',
}}
>
네 맞아요
</motion.button>
</div>
);
};

export default DesireCredit;
Loading