Skip to content
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
46 changes: 46 additions & 0 deletions src/api/fetchProblems.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { ApiError } from '../types/@common/api';
import type { Problem } from '../types/@common/problem';
import { transformProblems } from '../types/transformers/transformProblems';

export default async function fetchProblems(lessonId: string): Promise<Problem[]> {
const accessToken = localStorage.getItem('accessToken');

try {
const response = await fetch(`https://grav-it.inuappcenter.kr/api/v1/learning/${lessonId}/problems`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${accessToken}`,
},
});

if (!response.ok) {
let errorData: ApiError;

try {
const errorResponse = await response.json();
errorData = new ApiError(
errorResponse.message || `HTTP ${response.status}: ${response.statusText}`,
errorResponse.error || 'HTTP_ERROR'
);
} catch {
// JSON 파싱 실패 시 기본 에러 메시지
errorData = new ApiError(`HTTP ${response.status}: ${response.statusText}`, 'HTTP_ERROR');
}
throw errorData;
}

// 성공 응답 파싱
const data = await response.json();
console.log('서버 응답:', data); // 디버깅용

return transformProblems(data);
} catch (error) {
// 네트워크 에러나 기타 예외 처리
if (error instanceof ApiError) {
throw error;
}

throw new ApiError('네트워크 연결을 확인해주세요.', 'NETWORK_ERROR');
}
}
3 changes: 3 additions & 0 deletions src/assets/icons/button/floating-check.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
3 changes: 3 additions & 0 deletions src/assets/icons/button/floating-next.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
19 changes: 16 additions & 3 deletions src/components/chapter-page/Tooltip.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,29 @@
import { Link } from 'react-router-dom';

function Tooltip({ chapterName, chapterNumber, xp }: { chapterName: string; chapterNumber: number; xp: number }) {
function Tooltip({
chapterName,
chapterId,
unitId,
xp,
}: {
chapterName: string;
unitId: number;
xp: number;
chapterId: string;
}) {
return (
<div className="absolute transform top-full left-1/2 -translate-x-1/2 translate-y-3">
{/* 툴팁 꼬리 */}
<div className="w-0 h-0 mx-auto border-l-[12px] border-r-[12px] border-b-[12px] border-transparent border-b-[#FFB608]" />
{/* 툴팁 몸통 */}
<div className="bg-[#FFB608] flex flex-col p-4 w-[371px] h-[130px] rounded-2xl justify-between z-50">
<h2 className="text-2xl font-bold text-white text-start">{`${chapterName}: ${chapterNumber}챕터`}</h2>
<h2 className="text-2xl font-bold text-white text-start">{`${chapterName}: ${unitId}챕터`}</h2>
<Link
to="/lesson"
to={`/lesson/${chapterId}/${unitId}`}
className="h-[54px] bg-white text-xl font-semibold text-[#222124] rounded-2xl flex items-center justify-center"
state={{
chapterName: chapterName,
}}
>
학습 시작하기 (+{xp}xp)
</Link>
Expand Down
40 changes: 40 additions & 0 deletions src/components/lesson-page/FloatingButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { cn } from '../../utils/cn';
import CheckIcon from '../../assets/icons/button/floating-check.svg?react';
import NextIcon from '../../assets/icons/button/floating-next.svg?react';
import { useProblemContext } from '../../context/ProblemContext';

export default function FloatingButton() {
const { answerState, handleClickNext, checkAnswer } = useProblemContext();

function handleClickFloatingBtn() {
switch (answerState) {
case 'ANSWERING':
return null;
case 'SELECTED':
checkAnswer();
break;
case 'WRONG':
handleClickNext();
break;
case 'CORRECT':
handleClickNext();
}
}

return (
<button
disabled={answerState === 'ANSWERING'}
onClick={handleClickFloatingBtn}
className={cn(
'flex items-center justify-center cursor-pointer w-[80px] h-[80px] lg:w-[100px] lg:h-[100px] bg-[#009FFF] rounded-full fixed bottom-15 right-15 lg:bottom-15 lg:right-30',
answerState === 'ANSWERING' ? 'bg-gray-400 cursor-default' : ''
)}
>
{answerState === 'WRONG' || answerState === 'CORRECT' ? (
<NextIcon className="w-[18px]" />
) : (
<CheckIcon className="w-[25px]" />
)}
</button>
);
}
32 changes: 32 additions & 0 deletions src/components/lesson-page/Header.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import XIcon from '@/assets/icons/x.svg?react';
import TimerIcon from '@/assets/icons/timer.svg?react';
import { useEffect, useRef, useState } from 'react';
import formatTime from '../../utils/formatTime';

export default function Header({ handleOpenModal, chapterName }: { handleOpenModal: () => void; chapterName: string }) {
const startTimeRef = useRef(Date.now());
const [elapsedTime, setElapsedTime] = useState('00:00');
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);

useEffect(() => {
timerRef.current = setInterval(() => {
if (!startTimeRef.current) return;
const currentTime = Date.now() - startTimeRef.current;

setElapsedTime(formatTime(currentTime));
}, 1000);
}, []);

return (
<header className="w-full flex flex-row justify-between items-center px-10 py-6 z-40 bg-white">
<button onClick={handleOpenModal} className="cursor-pointer">
<XIcon />
</button>
<h1 className="text-2xl font-semibold">{chapterName}</h1>
<div className="flex flex-row items-center justify-between gap-2 w-[100px]">
<TimerIcon className="w-5 h-5" />
<p className="min-w-[70px] text-2xl font-semibold text-[#494949] text-start">{elapsedTime}</p>
</div>
</header>
);
}
10 changes: 10 additions & 0 deletions src/components/lesson-page/LessonProgress.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
export default function LessonProgress({ progress }: { progress: string }) {
return (
<div className="relative w-full min-h-1 rounded bg-[#BA00FF]/20 z-20 shrink-0">
<div
style={{ width: progress }}
className="absolute left-0 top-0 h-full bg-[#BA00FF] transition-[width] duration-300 ease-out"
/>
</div>
);
}
64 changes: 64 additions & 0 deletions src/components/lesson-page/OptionItem.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { useProblemContext } from '../../context/ProblemContext';
import { cn } from '../../utils/cn';
import WrongIcon from '@/assets/icons/x.svg?react';
import CorrectIcon from '@/assets/icons/button/floating-check.svg?react';

interface OptionItemProps {
number: number;
text: string;
isSelected: boolean;
isCorrectAnswer: boolean;
onClick: (idx: number) => void;
}

function OptionItem({ number, text, isSelected, onClick, isCorrectAnswer }: OptionItemProps) {
const { changeCurrentAnswer, answerState } = useProblemContext();
const isSubmitted = answerState === 'CORRECT' || answerState === 'WRONG';

const renderButtonContent = () => {
if (!isSubmitted) {
return number;
}

// 제출된 상태에서의 처리
if (isCorrectAnswer) {
return <CorrectIcon className="w-5" />;
} else if (isSelected && answerState === 'WRONG') {
return <WrongIcon className="w-5" />;
}

return number;
};

return (
<li
onClick={() => {
if (answerState === 'WRONG' || answerState == 'CORRECT') return;
if (isSelected) {
changeCurrentAnswer('');
} else {
onClick(number);
}
}}
className={cn(
'flex flex-row gap-4 items-center w-full p-4 cursor-pointer',
isSelected && 'bg-gray-300',
isSubmitted && 'cursor-default'
)}
>
<span
className={cn(
'inline-flex items-center justify-center rounded-full bg-white border border-[#6D6D6D] text-[#6D6D6D] w-10 h-10 font-bold',
isSelected && 'bg-black text-white border-gray-200',
isSelected && answerState === 'WRONG' ? 'bg-red-500 text-white' : '',
isCorrectAnswer && isSubmitted ? 'bg-green-500 text-white border-[#6D6D6D]' : ''
)}
>
{renderButtonContent()}
</span>
<span className="text-2xl font-medium text-[#6D6D6D]">{text}</span>
</li>
);
}

export default OptionItem;
26 changes: 26 additions & 0 deletions src/components/lesson-page/OptionsList.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { useProblemContext } from '../../context/ProblemContext';
import type { SelectableProblem } from '../../types/@common/problem';
import OptionItem from './OptionItem';

export default function OptionsList({ problem }: { problem: SelectableProblem }) {
const { changeCurrentAnswer, currentAnswer } = useProblemContext();

return (
<section className="flex flex-col max-w-[1188px] w-full">
<ol className="flex flex-col">
{problem.options.map((option, idx) => {
return (
<OptionItem
key={idx}
isCorrectAnswer={problem.correctAnswerIndex === idx + 1}
isSelected={Number(currentAnswer) === idx + 1}
onClick={() => changeCurrentAnswer((idx + 1).toString())}
number={idx + 1}
text={option}
/>
);
})}
</ol>
</section>
);
}
34 changes: 34 additions & 0 deletions src/components/lesson-page/problem/AnswerInput.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { cn } from '../../../utils/cn';
import { useProblemContext } from '../../../context/ProblemContext';
import type { DescriptiveProblem } from '../../../types/@common/problem';

export default function AnswerInput({ problem }: { problem: DescriptiveProblem }) {
const { answerState, currentAnswer, changeCurrentAnswer } = useProblemContext();

const isSubmitted = answerState === 'CORRECT' || answerState === 'WRONG';

const isCorrect = answerState === 'CORRECT';
return (
<div className="w-full max-w-[1188px] flex flex-col items-center gap-8">
<input
disabled={answerState === 'CORRECT' || answerState === 'WRONG'}
value={currentAnswer}
onChange={(e) => changeCurrentAnswer(e.target.value)}
className={cn(
'w-full h-[73px] bg-white rounded-lg border border-gray-200 pl-6 text-gray-600 text-2xl font-medium focus:outline-none focus:border-gray-400',
isCorrect && isSubmitted ? 'border-[#00A80B] text-[#00A80B]' : '',
!isCorrect && isSubmitted ? 'border-red-400 text-red-400' : ''
)}
placeholder="정답을 입력해주세요."
/>
{!isCorrect && isSubmitted ? (
<small className="self-start text-red-400 text-[20px] font-semibold">
정답: {problem.correctAnswers.join(', ')}
</small>
) : null}
{isCorrect && isSubmitted ? (
<small className="self-start text-[#00A80B] font-semibold text-[20px]">정답입니다!</small>
) : null}
</div>
);
}
15 changes: 15 additions & 0 deletions src/components/lesson-page/problem/AnswerSection.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { useProblemContext } from '../../../context/ProblemContext';
import OptionsList from '../OptionsList';
import AnswerInput from './AnswerInput';

export default function AnswerSection() {
const { currentProblem } = useProblemContext();

if (currentProblem?.optionType === 'descriptive') {
return <AnswerInput problem={currentProblem} />;
} else if (currentProblem?.optionType === 'selectable') {
return <OptionsList problem={currentProblem} />;
} else {
return <></>;
}
}
Loading