diff --git a/apps/service/package.json b/apps/service/package.json index df1c5302..14ba5313 100644 --- a/apps/service/package.json +++ b/apps/service/package.json @@ -20,10 +20,12 @@ "react": "^19", "react-dom": "^19", "react-hook-form": "^7.54.2", + "react-toastify": "^11.0.3", "swiper": "^11.2.5" }, "devDependencies": { "@svgr/webpack": "^8.1.0", + "@tailwindcss/postcss": "^4.0.4", "@tanstack/eslint-plugin-query": "^5.66.0", "@types/node": "^20", "@types/react": "^19", diff --git a/apps/service/public/svg/ic-arrow-grow-14.svg b/apps/service/public/svg/ic-arrow-grow-14.svg new file mode 100644 index 00000000..c2791d96 --- /dev/null +++ b/apps/service/public/svg/ic-arrow-grow-14.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/apps/service/public/svg/ic-comment-check-20.svg b/apps/service/public/svg/ic-comment-check-20.svg new file mode 100644 index 00000000..d3105d97 --- /dev/null +++ b/apps/service/public/svg/ic-comment-check-20.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/apps/service/public/svg/ic-next-gray.svg b/apps/service/public/svg/ic-next-gray.svg new file mode 100644 index 00000000..146cdbf1 --- /dev/null +++ b/apps/service/public/svg/ic-next-gray.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/service/public/svg/ic-next-small.svg b/apps/service/public/svg/ic-next-small.svg new file mode 100644 index 00000000..ed7f2cb2 --- /dev/null +++ b/apps/service/public/svg/ic-next-small.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/service/public/svg/ic-prescription-20.svg b/apps/service/public/svg/ic-prescription-20.svg new file mode 100644 index 00000000..5f25f14a --- /dev/null +++ b/apps/service/public/svg/ic-prescription-20.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/apps/service/public/svg/ic-question-18.svg b/apps/service/public/svg/ic-question-18.svg new file mode 100644 index 00000000..aacd721d --- /dev/null +++ b/apps/service/public/svg/ic-question-18.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/service/src/apis/controller/submit/postProblemSubmit.ts b/apps/service/src/apis/controller/submit/postProblemSubmit.ts index 2752cd74..2753eca8 100644 --- a/apps/service/src/apis/controller/submit/postProblemSubmit.ts +++ b/apps/service/src/apis/controller/submit/postProblemSubmit.ts @@ -1,10 +1,10 @@ import { client } from '@apis'; -const postProblemSubmit = async (publishId: string, problemId: string) => { +const postProblemSubmit = async (publishId: number, problemId: number) => { return await client.POST('/api/v1/client/problemSubmit', { body: { - publishId: Number(publishId), - problemId: Number(problemId), + publishId, + problemId, }, }); }; diff --git a/apps/service/src/app/layout.tsx b/apps/service/src/app/layout.tsx index 6654fd52..10f0b628 100644 --- a/apps/service/src/app/layout.tsx +++ b/apps/service/src/app/layout.tsx @@ -17,7 +17,7 @@ export const metadata: Metadata = { ? new URL('http://www.dev.math-pointer.com') : new URL('https://math-pointer.com'), title: '포인터', - description: '포인터', + description: '진단과 학습은 꼼꼼하게 성적 향상은 빠르게', robots: isDevelopment ? { index: false, diff --git a/apps/service/src/app/opengraph-image.png b/apps/service/src/app/opengraph-image.png index 968491f2..59801076 100644 Binary files a/apps/service/src/app/opengraph-image.png and b/apps/service/src/app/opengraph-image.png differ diff --git a/apps/service/src/app/problem/list/[publishId]/page.tsx b/apps/service/src/app/problem/list/[publishId]/page.tsx index 92a820bf..b62b9df7 100644 --- a/apps/service/src/app/problem/list/[publishId]/page.tsx +++ b/apps/service/src/app/problem/list/[publishId]/page.tsx @@ -25,7 +25,7 @@ const Page = () => { ); diff --git a/apps/service/src/app/problem/solve/[publishId]/[problemId]/SolveButtonsClient.tsx b/apps/service/src/app/problem/solve/[publishId]/[problemId]/SolveButtonsClient.tsx index b68c8ec6..19cf71d5 100644 --- a/apps/service/src/app/problem/solve/[publishId]/[problemId]/SolveButtonsClient.tsx +++ b/apps/service/src/app/problem/solve/[publishId]/[problemId]/SolveButtonsClient.tsx @@ -19,7 +19,7 @@ const SolveButtonsClient = ({ publishId, problemId }: SolveButtonsClientProps) = const handleClickDirect = async () => { trackEvent('problem_solve_direct_button_click'); - await postProblemSubmit(publishId, problemId); + await postProblemSubmit(Number(publishId), Number(problemId)); invalidateAll(); router.push(`/problem/solve/${publishId}/${problemId}/main-problem`); }; diff --git a/apps/service/src/app/problem/solve/[publishId]/[problemId]/child-problem/[childProblemId]/page.tsx b/apps/service/src/app/problem/solve/[publishId]/[problemId]/child-problem/[childProblemId]/page.tsx index 644b2246..303228b7 100644 --- a/apps/service/src/app/problem/solve/[publishId]/[problemId]/child-problem/[childProblemId]/page.tsx +++ b/apps/service/src/app/problem/solve/[publishId]/[problemId]/child-problem/[childProblemId]/page.tsx @@ -3,7 +3,9 @@ import { useState } from 'react'; import { useParams, useRouter } from 'next/navigation'; import { SubmitHandler, useForm } from 'react-hook-form'; import Image from 'next/image'; +import { Slide, ToastContainer } from 'react-toastify'; +import { copyImageToClipboard } from '@utils'; import { useGetChildProblemById } from '@apis'; import { putChildProblemSubmit, putChildProblemSkip } from '@apis'; import { @@ -13,11 +15,13 @@ import { NavigationFooter, ProgressHeader, SmallButton, - ChildAnswerCheckModalTemplate, TwoButtonModalTemplate, AnswerModalTemplate, Tag, ImageContainer, + CopyButton, + BottomSheet, + ChildAnswerCheckBottomSheetTemplate, } from '@components'; import { useInvalidate, useModal } from '@hooks'; import { components } from '@schema'; @@ -64,9 +68,7 @@ const Page = () => { } = data?.data ?? {}; const prevButtonLabel = - childProblemNumber === 1 - ? `메인 문제 ${problemNumber}번` - : `새끼 문제 ${problemNumber}-${childProblemNumber - 1}번`; + childProblemNumber === 1 ? '' : `새끼 문제 ${problemNumber}-${childProblemNumber - 1}번`; const nextButtonLabel = childProblemNumber === childProblemLength @@ -140,12 +142,35 @@ const Page = () => { onNext(); }; + const handleClickCopyImage = async () => { + if (!imageUrl) return; + await copyImageToClipboard(imageUrl); + }; + if (isLoading) { return <>; } return ( <> +
@@ -164,14 +189,18 @@ const Page = () => { )}
- + {`새끼 +
+ +
@@ -204,14 +233,16 @@ const Page = () => { onClickNext={isSubmitted ? handleClickNext : handleClickFooterSkipButton} /> - - + {}} + handleClickNext={handleClickNextProblemButton} handleClickShowAnswer={handleClickShowAnswer} /> - + + = { CORRECT: '정답', @@ -111,12 +113,35 @@ const Page = () => { router.push(`/report/${publishId}/${problemId}/analysis`); }; + const handleClickCopyImage = async () => { + if (!imageUrl) return; + await copyImageToClipboard(imageUrl); + }; + if (isLoading) { return <>; } return ( <> +
@@ -136,7 +161,7 @@ const Page = () => { )}
- + {`메인 { height={200} priority /> +
+ +
{isDirect && ( @@ -197,13 +225,14 @@ const Page = () => { onClickNext={isSubmitted ? handleClickNext : undefined} /> - - + - + ); }; diff --git a/apps/service/src/app/problem/solve/[publishId]/[problemId]/page.tsx b/apps/service/src/app/problem/solve/[publishId]/[problemId]/page.tsx index 1b51fd1c..a52d4370 100644 --- a/apps/service/src/app/problem/solve/[publishId]/[problemId]/page.tsx +++ b/apps/service/src/app/problem/solve/[publishId]/[problemId]/page.tsx @@ -1,18 +1,137 @@ 'use client'; - -import { useParams } from 'next/navigation'; +import { useEffect, useState } from 'react'; +import { useParams, useRouter } from 'next/navigation'; +import { SubmitHandler, useForm } from 'react-hook-form'; import Image from 'next/image'; +import { Slide, ToastContainer } from 'react-toastify'; + +import { + useGetProblemById, + putProblemSubmit, + useGetChildData, + postChildProblemSubmit, +} from '@apis'; +import { + AnswerInput, + Button, + Tag, + ProgressHeader, + SmallButton, + NavigationFooter, + TimeTag, + ImageContainer, + CopyButton, + MainAnswerCheckBottomSheetTemplate, + BottomSheet, +} from '@components'; +import { useInvalidate, useModal } from '@hooks'; +import { ProblemStatus } from '@types'; +import { useChildProblemContext } from '@/hooks/problem'; +import { copyImageToClipboard, trackEvent } from '@utils'; -import { useGetProblemThumbnail } from '@apis'; -import { ImageContainer, ProgressHeader, TimeTag } from '@components'; +const statusLabel: Record = { + CORRECT: '정답', + INCORRECT: '오답', + RETRY_CORRECT: '정답', + NOT_STARTED: '시작전', +}; -import SolveButtonsClient from './SolveButtonsClient'; +const statusColor: Record = { + CORRECT: 'green', + INCORRECT: 'red', + RETRY_CORRECT: 'green', + NOT_STARTED: 'gray', +}; const Page = () => { const { publishId, problemId } = useParams<{ publishId: string; problemId: string }>(); + const router = useRouter(); + const { childProblemLength } = useChildProblemContext(); + const { invalidateAll } = useInvalidate(); + + const { isOpen, openModal, closeModal } = useModal(); + const [result, setResult] = useState(); + const { register, handleSubmit, watch, setValue } = useForm<{ answer: string }>(); + const selectedAnswer = watch('answer'); + + // apis + const { data, isLoading } = useGetProblemById(publishId, problemId); + const { data: childData } = useGetChildData(publishId, problemId); + const childProblemId = childData?.data?.childProblemIds[0]; + + const { + number, + imageUrl, + recommendedMinute, + recommendedSecond, + status, + childProblemStatuses = [], + answerType = 'MULTIPLE_CHOICE', + answer, + } = data?.data ?? {}; + + const isSolved = status === 'CORRECT' || status === 'RETRY_CORRECT'; + const isSubmitted = status === 'CORRECT' || status === 'RETRY_CORRECT' || status === 'INCORRECT'; + const isDirect = + childProblemStatuses.length > 0 && + childProblemStatuses[childProblemStatuses.length - 1] === 'NOT_STARTED'; + + const hasChildProblem = childProblemStatuses.length > 0; + + const prevButtonLabel = + isDirect || childProblemLength === 0 + ? `메인 문제 ${number}번` + : `새끼 문제 ${number}-${childProblemLength}번`; + const nextButtonLabel = '해설 보기'; + + const handleSubmitAnswer: SubmitHandler<{ answer: string }> = async ({ answer }) => { + const { data } = await putProblemSubmit(publishId, problemId, answer); + const resultData = data?.data; + invalidateAll(); + + setResult(resultData); + if (resultData) { + openModal(); + } + }; - const { data, isLoading } = useGetProblemThumbnail(publishId, problemId); - const { number, imageUrl, recommendedMinute, recommendedSecond } = data?.data ?? {}; + const handleClickStepSolve = async () => { + trackEvent('problem_main_solve_step_solve_button_click'); + await postChildProblemSubmit(publishId, problemId); + invalidateAll(); + router.push(`/problem/solve/${publishId}/${problemId}/child-problem/${childProblemId}`); + }; + + const handleClickPrev = () => { + trackEvent('problem_main_solve_footer_prev_button_click'); + router.back(); + }; + + const handleClickNext = () => { + trackEvent('problem_main_solve_footer_show_commentary_button_click'); + router.push(`/report/${publishId}/${problemId}/analysis`); + }; + + const handleClickSolveAgain = () => { + trackEvent('problem_main_solve_check_modal_solve_again_button_click'); + closeModal(); + }; + + const handleClickShowReport = () => { + trackEvent('problem_main_solve_check_modal_commentary_button_click'); + router.push(`/report/${publishId}/${problemId}/analysis`); + }; + + const handleClickCopyImage = async () => { + if (!imageUrl) return; + await copyImageToClipboard(imageUrl); + }; + + useEffect(() => { + if (isSolved && answer) { + setValue('answer', answer); + } + }, [isSolved, answer, setValue]); if (isLoading) { return <>; @@ -20,25 +139,115 @@ const Page = () => { return ( <> - -
-
-

메인 문제 {number}번

- + + +
+
+
+
+

메인 문제 {number}번

+ +
+ {isSolved && ( + + 정답 + + )} + {status === 'INCORRECT' && ( + + 오답 + + )} +
+ + {`메인 +
+ +
+
+ + {hasChildProblem && ( +
+ + 단계별로 풀어보기 + +
+ )} + + {/* {!isDirect && childProblemStatuses.length > 0 && ( +
+

새끼 문제 정답

+
+ {childProblemStatuses.map((childProblemStatus, index) => ( +
+ + {number}-{index + 1}번 + + + {statusLabel[childProblemStatus]} + +
+ ))} +
+
+ )} */} +
+ +
+
+

정답 선택

+
+ + +
+
- - {`메인 - - -
+ + {/* */} + + + + ); }; diff --git a/apps/service/src/app/report/[publishId]/[problemId]/prescription/detail/page.tsx b/apps/service/src/app/report/[publishId]/[problemId]/prescription/detail/page.tsx index d35d9569..a34e4565 100644 --- a/apps/service/src/app/report/[publishId]/[problemId]/prescription/detail/page.tsx +++ b/apps/service/src/app/report/[publishId]/[problemId]/prescription/detail/page.tsx @@ -3,7 +3,7 @@ import { useSearchParams } from 'next/navigation'; import Image from 'next/image'; -import { Header, ImageContainer } from '@components'; +import { Button, Header, ImageContainer } from '@components'; import { useReportContext } from '@/hooks/report'; const Page = () => { @@ -34,10 +34,9 @@ const Page = () => { return ( <> -
-
-

{title}

-
+
+
+
& SVGRProps) => ( + + {title ? {title} : null} + + + + + + + + + + +); +const Memo = memo(SvgIcArrowGrow14); +export default Memo; diff --git a/apps/service/src/assets/svg/IcCommentCheck20.tsx b/apps/service/src/assets/svg/IcCommentCheck20.tsx new file mode 100644 index 00000000..cb1b6d15 --- /dev/null +++ b/apps/service/src/assets/svg/IcCommentCheck20.tsx @@ -0,0 +1,24 @@ +import type { SVGProps } from 'react'; +import { memo } from 'react'; +interface SVGRProps { + title?: string; + titleId?: string; +} +const SvgIcCommentCheck20 = ({ title, titleId, ...props }: SVGProps & SVGRProps) => ( + + {title ? {title} : null} + + + + + + + + + +); +const Memo = memo(SvgIcCommentCheck20); +export default Memo; diff --git a/apps/service/src/assets/svg/IcNextGray.tsx b/apps/service/src/assets/svg/IcNextGray.tsx new file mode 100644 index 00000000..5f9eddc4 --- /dev/null +++ b/apps/service/src/assets/svg/IcNextGray.tsx @@ -0,0 +1,17 @@ +import type { SVGProps } from 'react'; +import { memo } from 'react'; +interface SVGRProps { + title?: string; + titleId?: string; +} +const SvgIcNextGray = ({ title, titleId, ...props }: SVGProps & SVGRProps) => ( + + {title ? {title} : null} + + +); +const Memo = memo(SvgIcNextGray); +export default Memo; diff --git a/apps/service/src/assets/svg/IcNextSmall.tsx b/apps/service/src/assets/svg/IcNextSmall.tsx new file mode 100644 index 00000000..5a1943ea --- /dev/null +++ b/apps/service/src/assets/svg/IcNextSmall.tsx @@ -0,0 +1,17 @@ +import type { SVGProps } from 'react'; +import { memo } from 'react'; +interface SVGRProps { + title?: string; + titleId?: string; +} +const SvgIcNextSmall = ({ title, titleId, ...props }: SVGProps & SVGRProps) => ( + + {title ? {title} : null} + + +); +const Memo = memo(SvgIcNextSmall); +export default Memo; diff --git a/apps/service/src/assets/svg/IcPrescription20.tsx b/apps/service/src/assets/svg/IcPrescription20.tsx new file mode 100644 index 00000000..a597e0da --- /dev/null +++ b/apps/service/src/assets/svg/IcPrescription20.tsx @@ -0,0 +1,24 @@ +import type { SVGProps } from 'react'; +import { memo } from 'react'; +interface SVGRProps { + title?: string; + titleId?: string; +} +const SvgIcPrescription20 = ({ title, titleId, ...props }: SVGProps & SVGRProps) => ( + + {title ? {title} : null} + + + + + + + + + +); +const Memo = memo(SvgIcPrescription20); +export default Memo; diff --git a/apps/service/src/assets/svg/IcQuestion18.tsx b/apps/service/src/assets/svg/IcQuestion18.tsx new file mode 100644 index 00000000..eef89321 --- /dev/null +++ b/apps/service/src/assets/svg/IcQuestion18.tsx @@ -0,0 +1,20 @@ +import type { SVGProps } from 'react'; +import { memo } from 'react'; +interface SVGRProps { + title?: string; + titleId?: string; +} +const SvgIcQuestion18 = ({ title, titleId, ...props }: SVGProps & SVGRProps) => ( + + {title ? {title} : null} + + +); +const Memo = memo(SvgIcQuestion18); +export default Memo; diff --git a/apps/service/src/assets/svg/index.ts b/apps/service/src/assets/svg/index.ts index c18c0573..a67c2cc8 100644 --- a/apps/service/src/assets/svg/index.ts +++ b/apps/service/src/assets/svg/index.ts @@ -1,4 +1,6 @@ +export { default as IcArrowGrow14 } from './IcArrowGrow14'; export { default as IcCalendar } from './IcCalendar'; +export { default as IcCommentCheck20 } from './IcCommentCheck20'; export { default as IcCopy } from './IcCopy'; export { default as IcCorrect } from './IcCorrect'; export { default as IcDirect } from './IcDirect'; @@ -12,10 +14,14 @@ export { default as IcList } from './IcList'; export { default as IcMinusSmall } from './IcMinusSmall'; export { default as IcMinus } from './IcMinus'; export { default as IcNextBlack } from './IcNextBlack'; +export { default as IcNextGray } from './IcNextGray'; +export { default as IcNextSmall } from './IcNextSmall'; export { default as IcNext } from './IcNext'; export { default as IcNotice } from './IcNotice'; +export { default as IcPrescription20 } from './IcPrescription20'; export { default as IcPrevBlack } from './IcPrevBlack'; export { default as IcPrev } from './IcPrev'; +export { default as IcQuestion18 } from './IcQuestion18'; export { default as IcRight } from './IcRight'; export { default as IcSearch } from './IcSearch'; export { default as IcSecret } from './IcSecret'; diff --git a/apps/service/src/components/common/BottomSheet/BottomSheet.tsx b/apps/service/src/components/common/BottomSheet/BottomSheet.tsx new file mode 100644 index 00000000..8f409714 --- /dev/null +++ b/apps/service/src/components/common/BottomSheet/BottomSheet.tsx @@ -0,0 +1,59 @@ +'use client'; +import { useEffect, useState } from 'react'; +import { createPortal } from 'react-dom'; + +interface BottomSheetProps { + isOpen: boolean; + onClose: () => void; + children: React.ReactNode; +} + +const portalElement = typeof window !== 'undefined' && document.getElementById('modal'); + +const BottomSheet = ({ isOpen, onClose, children }: BottomSheetProps) => { + const [shouldRender, setShouldRender] = useState(false); + const [isAnimating, setIsAnimating] = useState(false); + const [showBackdrop, setShowBackdrop] = useState(false); + + useEffect(() => { + if (isOpen) { + setShouldRender(true); + document.body.style.overflow = 'hidden'; + + requestAnimationFrame(() => { + requestAnimationFrame(() => { + setIsAnimating(true); + setShowBackdrop(true); + }); + }); + } else if (shouldRender) { + setIsAnimating(false); + setShowBackdrop(false); + + const timeout = setTimeout(() => { + setShouldRender(false); + document.body.style.overflow = ''; + }, 300); + + return () => clearTimeout(timeout); + } + }, [isOpen, shouldRender]); + + if (!shouldRender || !portalElement) return null; + + return createPortal( +
+ {showBackdrop &&
} +
e.stopPropagation()}> + {children} +
+
, + portalElement + ); +}; + +export default BottomSheet; diff --git a/apps/service/src/components/common/BottomSheet/index.ts b/apps/service/src/components/common/BottomSheet/index.ts new file mode 100644 index 00000000..701759e5 --- /dev/null +++ b/apps/service/src/components/common/BottomSheet/index.ts @@ -0,0 +1,11 @@ +import BottomSheet from './BottomSheet'; +import BaseBottomSheetTemplate from './templates/BaseBottomSheetTemplate'; +import MainAnswerCheckBottomSheetTemplate from './templates/MainAnswerCheckBottomSheetTemplate'; +import ChildAnswerCheckBottomSheetTemplate from './templates/ChildAnswerCheckBottomSheetTemplate'; + +export { + BottomSheet, + BaseBottomSheetTemplate, + MainAnswerCheckBottomSheetTemplate, + ChildAnswerCheckBottomSheetTemplate, +}; diff --git a/apps/service/src/components/common/BottomSheet/templates/BaseBottomSheetTemplate.tsx b/apps/service/src/components/common/BottomSheet/templates/BaseBottomSheetTemplate.tsx new file mode 100644 index 00000000..413f9cbe --- /dev/null +++ b/apps/service/src/components/common/BottomSheet/templates/BaseBottomSheetTemplate.tsx @@ -0,0 +1,56 @@ +import { IcNextGray } from '@svg'; + +interface BottomSheetButtonProps { + variant?: 'default' | 'recommend'; + label: string; + onClick?: () => void; +} + +const BaseBottomSheetTemplate = ({ children }: { children: React.ReactNode }) => { + return ( +
+ {children} +
+ ); +}; + +const BottomSheetContent = ({ children }: { children: React.ReactNode }) => { + return ( +
+ {children} +
+ ); +}; + +const BottomSheetText = ({ text }: { text: string }) => { + return

{text}

; +}; + +const BottomSheetButtonSection = ({ children }: { children: React.ReactNode }) => { + return
{children}
; +}; + +const BottomSheetButton = ({ variant = 'default', label, onClick }: BottomSheetButtonProps) => { + return ( +
+
+

{label}

+ {variant === 'recommend' && ( +
+ 추천 +
+ )} +
+ +
+ ); +}; + +BaseBottomSheetTemplate.Content = BottomSheetContent; +BaseBottomSheetTemplate.Text = BottomSheetText; +BaseBottomSheetTemplate.ButtonSection = BottomSheetButtonSection; +BaseBottomSheetTemplate.Button = BottomSheetButton; + +export default BaseBottomSheetTemplate; diff --git a/apps/service/src/components/common/BottomSheet/templates/ChildAnswerCheckBottomSheetTemplate.tsx b/apps/service/src/components/common/BottomSheet/templates/ChildAnswerCheckBottomSheetTemplate.tsx new file mode 100644 index 00000000..d90be10e --- /dev/null +++ b/apps/service/src/components/common/BottomSheet/templates/ChildAnswerCheckBottomSheetTemplate.tsx @@ -0,0 +1,52 @@ +import { IcCorrect, IcIncorrect } from '@svg'; +import { components } from '@schema'; + +import BaseBottomSheetTemplate from './BaseBottomSheetTemplate'; + +type ChildProblemSubmitUpdateResponse = components['schemas']['ChildProblemSubmitUpdateResponse']; + +interface ChildAnswerCheckBottomSheetTemplateProps { + result: ChildProblemSubmitUpdateResponse | undefined; + onClose: () => void; + handleClickShowPointing?: () => void; + handleClickNext?: () => void; + handleClickShowAnswer?: () => void; +} + +const ChildAnswerCheckBottomSheetTemplate = ({ + result, + onClose, + handleClickShowPointing, + handleClickNext, + handleClickShowAnswer, +}: ChildAnswerCheckBottomSheetTemplateProps) => { + if (!result) return null; + + const { status } = result; + const isCorrect = status === 'CORRECT' || status === 'RETRY_CORRECT'; + + return ( + + + {isCorrect ? : } + + + + + + {!isCorrect && ( + <> + + + + )} + + + ); +}; + +export default ChildAnswerCheckBottomSheetTemplate; diff --git a/apps/service/src/components/common/BottomSheet/templates/MainAnswerCheckBottomSheetTemplate.tsx b/apps/service/src/components/common/BottomSheet/templates/MainAnswerCheckBottomSheetTemplate.tsx new file mode 100644 index 00000000..78e7737f --- /dev/null +++ b/apps/service/src/components/common/BottomSheet/templates/MainAnswerCheckBottomSheetTemplate.tsx @@ -0,0 +1,41 @@ +import { IcCorrect, IcIncorrect } from '@svg'; +import { ProblemStatus } from '@types'; + +import BaseBottomSheetTemplate from './BaseBottomSheetTemplate'; + +interface MainAnswerCheckModalTemplateProps { + result: ProblemStatus | undefined; + onClose: () => void; + handleClickStepSolve: () => void; + handleClickShowReport: () => void; +} + +const MainAnswerCheckModalTemplate = ({ + result, + onClose, + handleClickStepSolve, + handleClickShowReport, +}: MainAnswerCheckModalTemplateProps) => { + if (!result) return null; + const isCorrect = result === 'CORRECT' || result === 'RETRY_CORRECT'; + + return ( + + + {isCorrect ? : } + + + + + + {!isCorrect && } + + + ); +}; + +export default MainAnswerCheckModalTemplate; diff --git a/apps/service/src/components/common/ImageContainer.tsx b/apps/service/src/components/common/ImageContainer.tsx index fd36d6a3..f4deb905 100644 --- a/apps/service/src/components/common/ImageContainer.tsx +++ b/apps/service/src/components/common/ImageContainer.tsx @@ -6,7 +6,9 @@ interface ImageContainerProps { } const ImageContainer = ({ children, className = '' }: ImageContainerProps) => { - return
{children}
; + return ( +
{children}
+ ); }; export default ImageContainer; diff --git a/apps/service/src/components/common/NavigationFooter.tsx b/apps/service/src/components/common/NavigationFooter.tsx index 7baf63da..41c61491 100644 --- a/apps/service/src/components/common/NavigationFooter.tsx +++ b/apps/service/src/components/common/NavigationFooter.tsx @@ -16,12 +16,12 @@ const NavigationFooter = ({ return (
- {prevLabel && onClickPrev && ( + {prevLabel && prevLabel !== '' && onClickPrev && ( )}
- {nextLabel && onClickNext && ( + {nextLabel && nextLabel !== '' && onClickNext && ( )}
diff --git a/apps/service/src/components/common/ProgressHeader.tsx b/apps/service/src/components/common/ProgressHeader.tsx index 563cd542..d0b5fa84 100644 --- a/apps/service/src/components/common/ProgressHeader.tsx +++ b/apps/service/src/components/common/ProgressHeader.tsx @@ -22,7 +22,12 @@ const ProgressHeader = ({ progress }: ProgressHeaderProps) => { return (
- +
{progress !== undefined && }
diff --git a/apps/service/src/components/common/index.ts b/apps/service/src/components/common/index.ts index 9c20a662..211a1378 100644 --- a/apps/service/src/components/common/index.ts +++ b/apps/service/src/components/common/index.ts @@ -13,6 +13,7 @@ import ImageContainer from './ImageContainer'; export * from './Buttons'; export * from './Inputs'; export * from './Modals'; +export * from './BottomSheet'; export { Tag, diff --git a/apps/service/src/components/problem/DontKnowButton.tsx b/apps/service/src/components/problem/DontKnowButton.tsx new file mode 100644 index 00000000..ed5969ae --- /dev/null +++ b/apps/service/src/components/problem/DontKnowButton.tsx @@ -0,0 +1,20 @@ +import React from 'react'; + +import { IcQuestion18 } from '@svg'; + +interface DontKnowButtonProps { + onClick: () => void; +} + +const DontKnowButton = ({ onClick }: DontKnowButtonProps) => { + return ( + + ); +}; + +export default DontKnowButton; diff --git a/apps/service/src/components/problem/ProblemStatusCard.tsx b/apps/service/src/components/problem/ProblemStatusCard.tsx index c649d498..3f5e0d87 100644 --- a/apps/service/src/components/problem/ProblemStatusCard.tsx +++ b/apps/service/src/components/problem/ProblemStatusCard.tsx @@ -1,18 +1,20 @@ 'use client'; import { useRouter } from 'next/navigation'; -import React, { useState } from 'react'; +import React, { useState, useEffect } from 'react'; import { Button, StatusIcon, StatusTag } from '@components'; import { trackEvent } from '@utils'; import { components } from '@schema'; import { IcDown, IcUp } from '@svg'; +import { postProblemSubmit } from '@apis'; +import { useInvalidate } from '@hooks'; type ProblemFeedProgressesGetResponse = components['schemas']['ProblemFeedProgressesGetResponse']; interface ProblemStatusCardProps { mainProblemNumber: number; - publishId: string; + publishId: number; problemData: ProblemFeedProgressesGetResponse; } @@ -21,18 +23,28 @@ const ProblemStatusCard = ({ publishId, problemData, }: ProblemStatusCardProps) => { + const { invalidateAll } = useInvalidate(); const { problemId, status, childProblemStatuses } = problemData; const router = useRouter(); const [isOpen, setIsOpen] = useState( !childProblemStatuses?.every((childStatus) => childStatus === 'NOT_STARTED') ); + useEffect(() => { + setIsOpen(!childProblemStatuses?.every((childStatus) => childStatus === 'NOT_STARTED')); + }, [childProblemStatuses]); + const isSolved = status === 'CORRECT' || status === 'RETRY_CORRECT' || status === 'INCORRECT'; + const hasChildProblem = childProblemStatuses && childProblemStatuses?.length > 0; - const handleClickSolveButton = () => { + const handleClickSolveButton = async () => { trackEvent('problem_list_card_solve_button_click', { problemId: problemId ?? '', }); + + if (!problemId) return; + await postProblemSubmit(publishId, problemId); + invalidateAll(); router.push(`/problem/solve/${publishId}/${problemId}`); }; @@ -50,12 +62,14 @@ const ProblemStatusCard = ({

메인 문제 {mainProblemNumber}번

-
setIsOpen((prev) => !prev)}> - {isOpen ? : } -
+ {hasChildProblem && ( +
setIsOpen((prev) => !prev)}> + {isOpen ? : } +
+ )}
- {isOpen && ( + {isOpen && hasChildProblem && (
    {childProblemStatuses?.map((childStatus, index) => (
  • diff --git a/apps/service/src/components/problem/StepSolveButton.tsx b/apps/service/src/components/problem/StepSolveButton.tsx new file mode 100644 index 00000000..99dbef89 --- /dev/null +++ b/apps/service/src/components/problem/StepSolveButton.tsx @@ -0,0 +1,23 @@ +import React from 'react'; + +import { IcArrowGrow14 } from '@svg'; + +interface StepSolveButtonProps { + onClick: () => void; +} + +const StepSolveButton = ({ onClick }: StepSolveButtonProps) => { + return ( +
    + 잘 모르겠다면 + +
    + ); +}; + +export default StepSolveButton; diff --git a/apps/service/src/components/problem/index.ts b/apps/service/src/components/problem/index.ts index f3652001..66a90549 100644 --- a/apps/service/src/components/problem/index.ts +++ b/apps/service/src/components/problem/index.ts @@ -1,5 +1,7 @@ import ProblemCalandar from './ProblemCalandar'; import DayProblemCard from './DayProblemCard'; import ProblemStatusCard from './ProblemStatusCard'; +import DontKnowButton from './DontKnowButton'; +import StepSolveButton from './StepSolveButton'; -export { DayProblemCard, ProblemCalandar, ProblemStatusCard }; +export { DayProblemCard, ProblemCalandar, ProblemStatusCard, DontKnowButton, StepSolveButton }; diff --git a/apps/service/src/components/report/PointingImageContainer.tsx b/apps/service/src/components/report/PointingImageContainer.tsx new file mode 100644 index 00000000..2c477cd9 --- /dev/null +++ b/apps/service/src/components/report/PointingImageContainer.tsx @@ -0,0 +1,32 @@ +import Image from 'next/image'; + +import { IcCommentCheck20, IcPrescription20 } from '@svg'; +interface PointingImageContainerProps { + src: string; + variant: 'pointing' | 'prescription'; +} + +const PointingImageContainer = ({ src, variant }: PointingImageContainerProps) => { + return ( +
    +
    + {variant === 'pointing' ? ( + + ) : ( + + )} +

    {variant === 'pointing' ? '포인팅' : '처방'}

    +
    + {variant +
    + ); +}; + +export default PointingImageContainer; diff --git a/apps/service/src/components/report/PrescriptionCard.tsx b/apps/service/src/components/report/PrescriptionCard.tsx index daf83a57..0cfbd57b 100644 --- a/apps/service/src/components/report/PrescriptionCard.tsx +++ b/apps/service/src/components/report/PrescriptionCard.tsx @@ -1,5 +1,10 @@ -import { SmallButton } from '@components'; -import { IcStatusCorrect, IcStatusIncorrect, IcStatusNotStarted, IcStatusRetried } from '@svg'; +import { + IcNextSmall, + IcStatusCorrect, + IcStatusIncorrect, + IcStatusNotStarted, + IcStatusRetried, +} from '@svg'; type PrescriptionCardProps = { status?: 'CORRECT' | 'INCORRECT' | 'RETRY_CORRECT' | 'IN_PROGRESS' | 'NOT_STARTED'; @@ -28,7 +33,10 @@ const PrescriptionCard = ({ status = 'NOT_STARTED', title, onClick }: Prescripti {statusIcon(status)}

    {title}

- 진단 받기 +
+ 포인팅 + +
); }; diff --git a/apps/service/src/components/report/index.ts b/apps/service/src/components/report/index.ts index fca76875..998a2070 100644 --- a/apps/service/src/components/report/index.ts +++ b/apps/service/src/components/report/index.ts @@ -1,4 +1,5 @@ import TabMenu from './TabMenu'; import PrescriptionCard from './PrescriptionCard'; +import PointingImageContainer from './PointingImageContainer'; -export { TabMenu, PrescriptionCard }; +export { TabMenu, PrescriptionCard, PointingImageContainer }; diff --git a/apps/service/src/contexts/ProblemContext.tsx b/apps/service/src/contexts/ProblemContext.tsx index bad04eb4..9eae3a54 100644 --- a/apps/service/src/contexts/ProblemContext.tsx +++ b/apps/service/src/contexts/ProblemContext.tsx @@ -36,7 +36,7 @@ export const ProblemProvider = ({ children }: { children: React.ReactNode }) => const onNext = () => { if (step === childProblemIds.length - 1) { - router.push(`${baseUrl}/main-problem`); + router.push(baseUrl); } else if (step < childProblemIds.length - 1) { router.push(`${baseUrl}/child-problem/${childProblemIds[step + 1]}`); setStep(step + 1); diff --git a/apps/service/src/utils/common/image.ts b/apps/service/src/utils/common/image.ts new file mode 100644 index 00000000..ae8e14d7 --- /dev/null +++ b/apps/service/src/utils/common/image.ts @@ -0,0 +1,47 @@ +import { toast } from 'react-toastify'; + +/** + * 이미지를 클립보드에 복사하는 함수 + * @param imageUrl 복사할 이미지의 URL + * @returns Promise + */ +export const copyImageToClipboard = async (imageUrl: string): Promise => { + if (!imageUrl) return; + + try { + const img = document.createElement('img'); + img.crossOrigin = 'anonymous'; + img.src = imageUrl; + + img.onload = async () => { + const canvas = document.createElement('canvas'); + canvas.width = img.width; + canvas.height = img.height; + + const ctx = canvas.getContext('2d'); + ctx?.drawImage(img, 0, 0); + + canvas.toBlob(async (blob) => { + if (!blob) { + toast.error('이미지를 변환하는 데 실패했습니다.'); + return; + } + + try { + await navigator.clipboard.write([new ClipboardItem({ 'image/png': blob })]); + toast.success('이미지가 클립보드에 복사되었어요.'); + } catch (err) { + console.error('이미지 복사 실패:', err); + toast.error('이미지 복사에 실패했습니다.'); + } + }, 'image/png'); + }; + + img.onerror = () => { + toast.error('이미지를 불러오는 데 실패했습니다.'); + }; + } catch (error) { + console.error('이미지 처리 실패:', error); + toast.error('이미지 복사에 실패했습니다.'); + } +}; diff --git a/apps/service/src/utils/common/index.ts b/apps/service/src/utils/common/index.ts index 884efe34..3795e719 100644 --- a/apps/service/src/utils/common/index.ts +++ b/apps/service/src/utils/common/index.ts @@ -1,2 +1,3 @@ export * from './auth'; export * from './trackEvent'; +export * from './image'; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ea32efcb..1f3bc2e4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -195,6 +195,9 @@ importers: react-hook-form: specifier: ^7.54.2 version: 7.54.2(react@19.0.0) + react-toastify: + specifier: ^11.0.3 + version: 11.0.3(react-dom@19.0.0(react@19.0.0))(react@19.0.0) swiper: specifier: ^11.2.5 version: 11.2.5 @@ -202,6 +205,9 @@ importers: '@svgr/webpack': specifier: ^8.1.0 version: 8.1.0(typescript@5.7.3) + '@tailwindcss/postcss': + specifier: ^4.0.4 + version: 4.0.4 '@tanstack/eslint-plugin-query': specifier: ^5.66.0 version: 5.66.0(eslint@9.19.0(jiti@2.4.2))(typescript@5.7.3)