diff --git a/src/apis/notifications.ts b/src/apis/notifications.ts index 6c123535..80804d06 100644 --- a/src/apis/notifications.ts +++ b/src/apis/notifications.ts @@ -17,7 +17,15 @@ const NOTIFICATION_APIS = { }, }; +const NOTIFICATION_MISSIONS_APIS = { + // POST /notifications/missions/remind - 미션 알림 + remind: (seconds: number) => { + return apiInstance.post('/notifications/missions/remind', { seconds }); + }, +}; + export default NOTIFICATION_APIS; +export { NOTIFICATION_MISSIONS_APIS }; export const useNotifyUrging = (options?: UseMutationOptions) => { return useMutationHandleError({ @@ -25,3 +33,10 @@ export const useNotifyUrging = (options?: UseMutationOptions) => { + return useMutationHandleError({ + mutationFn: NOTIFICATION_MISSIONS_APIS.remind, + ...options, + }); +}; diff --git a/src/app/feed/FeedItem.tsx b/src/app/feed/FeedItem.tsx index 32fa9e47..edb2067c 100644 --- a/src/app/feed/FeedItem.tsx +++ b/src/app/feed/FeedItem.tsx @@ -140,6 +140,8 @@ const missionNameCss = css({ const remarkCss = css({ textStyle: 'body2', color: 'text.primary', + whiteSpace: 'pre-wrap', + wordBreak: 'break-word', }); const captionCss = css({ diff --git a/src/app/guest/mission/stopwatch/page.tsx b/src/app/guest/mission/stopwatch/page.tsx index 0a38cd17..a08f867e 100644 --- a/src/app/guest/mission/stopwatch/page.tsx +++ b/src/app/guest/mission/stopwatch/page.tsx @@ -8,28 +8,30 @@ import Header from '@/components/Header/Header'; import Stopwatch from '@/components/Stopwatch/Stopwatch'; import { EVENT_LOG_CATEGORY, EVENT_LOG_NAME } from '@/constants/eventLog'; import { ROUTER } from '@/constants/router'; -import useStopwatch from '@/hooks/mission/stopwatch/useStopwatch'; -import useStopwatchStatus from '@/hooks/mission/stopwatch/useStopwatchStatus'; +import useStopwatchLogic from '@/hooks/mission/stopwatch/useStopwatchLogic'; +import useStopwatchStatus, { StopwatchStep } from '@/hooks/mission/stopwatch/useStopwatchStatus'; import useModal from '@/hooks/useModal'; import useSearchParamsTypedValue from '@/hooks/useSearchParamsTypedValue'; import { eventLogger } from '@/utils'; +import { formatMMSS } from '@/utils/time'; import { css } from '@styled-system/css'; -const GUEST_MISSION_ID = ''; - export default function GuestMissionStopwatchPage() { const router = useRouter(); const category = useGetCategory(); const { step, prevStep, stepLabel, onNextStep } = useStopwatchStatus(); - const { seconds, minutes, stepper } = useStopwatch(step, GUEST_MISSION_ID); + const { second } = useStopwatchLogic({ status: step }); + + const { formattedMinutes: minutes, formattedSeconds: seconds } = formatMMSS(second); + const stepper = second < 60 ? 0 : Math.floor(second / 60 / 10); const { isOpen, openModal, closeModal } = useModal(); const onFinishButtonClick = () => { eventLogger.logEvent(EVENT_LOG_NAME.STOPWATCH.CLICK_FINISH_BUTTON, EVENT_LOG_CATEGORY.STOPWATCH, { category }); openModal(); - onNextStep('stop'); + onNextStep(StopwatchStep.stop); }; const onFinish = () => { @@ -56,7 +58,7 @@ export default function GuestMissionStopwatchPage() { stopTime: Number(minutes) * 60 + Number(seconds), isGuest: true, }); - onNextStep('stop'); + onNextStep(StopwatchStep.stop); }; const onStart = () => { @@ -64,7 +66,7 @@ export default function GuestMissionStopwatchPage() { category, isGuest: true, }); - onNextStep('progress'); + onNextStep(StopwatchStep.progress); }; return ( @@ -90,14 +92,14 @@ export default function GuestMissionStopwatchPage() { />
- {step === 'ready' && ( + {step === StopwatchStep.ready && (
)} - {step === 'progress' && ( + {step === StopwatchStep.progress && ( <> + + )} + {step === 'progress' && ( + <> + + + + )} + {step === 'stop' && ( + <> + + + + )} +
+ ); +} + +export default ButtonSection; + +const useStopwatch = (missionId: string) => { + const { onNextStep } = useStopwatchStepContext(); + const { openMidOutModal, openFinalModal } = useStopwatchModalContext(); + + const startAction = () => { + onNextStep(StopwatchStep.progress); + + // 이전 미션 기록 삭제 - 강제 접근 이슈 + checkPrevProgressMission(missionId); + setMissionTimeStack(missionId, 'start'); + }; + + const onInitStart = () => { + startAction(); + setMissionData(missionId); + }; + + const onMidStart = () => { + startAction(); + }; + + const onStop = () => { + onNextStep(StopwatchStep.stop); + setMissionTimeStack(missionId, 'stop'); + }; + + const onRestart = () => { + setMissionTimeStack(missionId, 'restart'); + onNextStep(StopwatchStep.progress); + }; + + const onMidOut = () => { + onNextStep(StopwatchStep.stop); + openMidOutModal(); + }; + + const onFinish = () => { + onNextStep(StopwatchStep.stop); + openFinalModal(); + }; + + return { + onInitStart, + onMidStart, + onStop, + onMidOut, + onFinish, + onRestart, + }; +}; + +const buttonContainerCss = css({ + margin: '28px auto', + display: 'flex', + justifyContent: 'center', + gap: '12px', +}); + +const opacityAnimation = css({ + animation: 'fadeIn .7s', +}); + +const fixedButtonContainerCss = css({ + position: 'fixed', + left: '16px', + right: '16px', + bottom: '16px', + width: '100%', + maxWidth: 'calc(475px - 48px)', + margin: '0 auto', + '@media (max-width: 475px)': { + maxWidth: 'calc(100vw - 48px)', + }, +}); + +const MAX_SECONDS = 3600; // max 1 hour + +const useInitTimeSetting = ({ missionId }: { missionId: string }) => { + const { onNextStep } = useStopwatchStepContext(); + const { setTime: setSecond } = useStopwatchTimeContext(); + + const [isPending, setIsPending] = useState(true); + const settingInitTime = () => { + const initSeconds = getProgressMissionTime(missionId); + + if (!initSeconds) return false; + if (initSeconds >= MAX_SECONDS) { + setSecond(MAX_SECONDS); + } else { + setSecond(initSeconds); + } + return true; + }; + + // 화면 visible 상태로 변경 시, 시간을 다시 세팅 + useVisibilityStateVisible(() => { + setIsPending(true); + settingInitTime(); + setIsPending(false); + }); + + useEffect(() => { + // 해당 미션을 이어 가는 경우. init time setting + const isSettingInit = settingInitTime(); + setIsPending(false); + if (!isSettingInit) return; + + const prevStatus = getPrevProgressMissionStatus(missionId); + prevStatus && onNextStep?.(prevStatus); // 바로 재시작 + }, []); + + return { isPending }; +}; diff --git a/src/app/mission/[id]/stopwatch/Header.tsx b/src/app/mission/[id]/stopwatch/Header.tsx new file mode 100644 index 00000000..a6226608 --- /dev/null +++ b/src/app/mission/[id]/stopwatch/Header.tsx @@ -0,0 +1,19 @@ +import Header from '@/components/Header/Header'; +import { StopwatchStep } from '@/hooks/mission/stopwatch/useStopwatchStatus'; + +import { useStopwatchModalContext } from './Modal.context'; +import { useStopwatchStepContext } from './Stopwatch.context'; + +function StopwatchHeader() { + const { onNextStep } = useStopwatchStepContext(); + const { openBackModal } = useStopwatchModalContext(); + + const onBackAction = () => { + onNextStep(StopwatchStep.stop); + openBackModal(); + }; + + return
; +} + +export default StopwatchHeader; diff --git a/src/app/mission/[id]/stopwatch/Modal.context.tsx b/src/app/mission/[id]/stopwatch/Modal.context.tsx new file mode 100644 index 00000000..fd521eec --- /dev/null +++ b/src/app/mission/[id]/stopwatch/Modal.context.tsx @@ -0,0 +1,172 @@ +'use client'; + +import { createContext, type PropsWithChildren, useCallback, useContext, useMemo } from 'react'; +import { useRouter } from 'next/navigation'; +import Dialog from '@/components/Dialog/Dialog'; +import Loading from '@/components/Loading'; +import { EVENT_LOG_CATEGORY, EVENT_LOG_NAME } from '@/constants/eventLog'; +import { ROUTER } from '@/constants/router'; +import useModal from '@/hooks/useModal'; +import { eventLogger } from '@/utils'; +import { removeProgressMissionData } from '@/utils/storage/progressMission'; + +import { useSubmit } from './index.hooks'; +import { useStopwatchStepContext, useStopwatchTimeContext } from './Stopwatch.context'; + +interface ModalContextProps { + openMidOutModal: () => void; + openFinalModal: () => void; + openBackModal: () => void; +} + +const ModalContext = createContext({ + openMidOutModal: () => {}, + openFinalModal: () => {}, + openBackModal: () => {}, +}); + +function ModalContextProvider({ + children, + missionId, +}: PropsWithChildren<{ + missionId: string; +}>) { + const router = useRouter(); + const { time } = useStopwatchTimeContext(); + const { prevStep, onNextStep } = useStopwatchStepContext(); + + const { onSubmit, isSubmitLoading } = useSubmit({ missionId, second: time }); + + const { isOpen: isFinalModalOpen, openModal: openFinalModal, closeModal: closeFinalModal } = useModal(); + const { isOpen: isBackModalOpen, openModal: openBackModal, closeModal: closeBackModal } = useModal(); + const { isOpen: isMidOutModalOpen, openModal: openMidOutModal, closeModal: closeMidOutModal } = useModal(); + + const value = useMemo( + () => ({ + openMidOutModal, + openFinalModal, + openBackModal, + }), + [openBackModal, openFinalModal, openMidOutModal], + ); + + const logData = useMemo(() => ({ finishTime: time }), [time]); + + const onCancel = useCallback(() => { + eventLogger.logEvent(EVENT_LOG_NAME.STOPWATCH.CLICK_CANCEL, EVENT_LOG_CATEGORY.STOPWATCH, logData); + onNextStep(prevStep); + }, [logData, onNextStep, prevStep]); + + const onFinish = useCallback(() => { + onSubmit(); + }, [onSubmit]); + + // 뒤로가기 버튼 눌렀을 때 + const onExit = useCallback(() => { + router.replace(ROUTER.MISSION.DETAIL(missionId)); + removeProgressMissionData(); + }, [missionId, router]); + + return ( + + {children} + + + + {isSubmitLoading && } + + ); +} + +export default ModalContextProvider; + +export const useStopwatchModalContext = () => { + const context = useContext(ModalContext); + if (!context) { + throw new Error('useStopwatchModalContext must be used within a StopwatchProvider'); + } + + return context; +}; + +interface DialogProps { + isOpen: boolean; + onClose: VoidFunction; + onConfirm: VoidFunction; + onCancel: VoidFunction; + logData?: Record; +} + +export function FinalDialog({ onConfirm, logData, ...props }: DialogProps) { + const _onConfirm = () => { + logData && eventLogger.logEvent(EVENT_LOG_NAME.STOPWATCH.CLICK_FINISH, EVENT_LOG_CATEGORY.STOPWATCH); + onConfirm(); + }; + return ( + + ); +} + +export function BackDialog({ onConfirm, logData, ...props }: DialogProps) { + const _onConfirm = () => { + logData && eventLogger.logEvent(EVENT_LOG_NAME.STOPWATCH.CLICK_BACK, EVENT_LOG_CATEGORY.STOPWATCH); + onConfirm(); + }; + + return ( + + ); +} + +export function MidOutDialog({ onConfirm, logData, ...props }: DialogProps) { + const _onConfirm = () => { + logData && eventLogger.logEvent(EVENT_LOG_NAME.STOPWATCH.CLICK_MID_OUT, EVENT_LOG_CATEGORY.STOPWATCH); + onConfirm(); + }; + + return ( + + ); +} diff --git a/src/app/mission/[id]/stopwatch/Stopwatch.context.tsx b/src/app/mission/[id]/stopwatch/Stopwatch.context.tsx new file mode 100644 index 00000000..1a9faf88 --- /dev/null +++ b/src/app/mission/[id]/stopwatch/Stopwatch.context.tsx @@ -0,0 +1,121 @@ +'use client'; + +import { + createContext, + type Dispatch, + type PropsWithChildren, + type SetStateAction, + useContext, + useEffect, +} from 'react'; +import Loading from '@/components/Loading'; +import { EVENT_LOG_CATEGORY, EVENT_LOG_NAME } from '@/constants/eventLog'; +import useStopwatchSeconds from '@/hooks/mission/stopwatch/useStopwatchLogic'; +import useStopwatchStatus, { StopwatchStep } from '@/hooks/mission/stopwatch/useStopwatchStatus'; +import { eventLogger } from '@/utils'; +import { formatMMSS } from '@/utils/time'; + +import { useSubmit } from './index.hooks'; +import ModalContextProvider from './Modal.context'; + +interface TimeContextProps { + minutes: string; + seconds: string; + setTime: Dispatch>; + time: number; +} + +const StopwatchTimeContext = createContext({ + minutes: '00', + seconds: '00', + setTime: () => {}, + time: 0, +}); + +interface StepContextProps { + step: StopwatchStep; + prevStep: StopwatchStep; + onNextStep: (nextStep: StopwatchStep) => void; +} + +export const StopwatchStepContext = createContext({ + step: StopwatchStep.ready, + prevStep: StopwatchStep.ready, + onNextStep: () => {}, +}); + +function StopwatchProvider({ + children, + missionId, +}: PropsWithChildren<{ + missionId: string; +}>) { + const stepValue = useStopwatchStatus(); + const { second, setSecond, isFinished } = useStopwatchSeconds({ status: stepValue.step }); + const { formattedMinutes, formattedSeconds } = formatMMSS(second); + + const { isSubmitLoading, onSubmit } = useSubmit({ missionId, second }); + + const timeValue = { + minutes: formattedMinutes, + seconds: formattedSeconds, + setTime: setSecond, + time: second, + }; + + const onAutoFinish = () => { + eventLogger.logEvent(EVENT_LOG_NAME.STOPWATCH.CLICK_AUTO_FINISH, EVENT_LOG_CATEGORY.STOPWATCH, { + finishTime: second, + }); + onSubmit(); + }; + + useEffect(() => { + if (isFinished) { + onAutoFinish(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isFinished]); + + useEffect(() => { + // 10분 넘으면 이벤트 기록 + if (Number(formattedMinutes) === 10) { + const recordTenMinuteEvent = () => { + eventLogger.logEvent(EVENT_LOG_NAME.STOPWATCH.COMPLETE_TEM_MINUTE, EVENT_LOG_CATEGORY.STOPWATCH, { + missionId, + }); + }; + recordTenMinuteEvent(); + } + }, [formattedMinutes, missionId]); + + return ( + <> + + + {children} + + + {isSubmitLoading && } + + ); +} + +export default StopwatchProvider; + +export const useStopwatchTimeContext = () => { + const context = useContext(StopwatchTimeContext); + if (!context) { + throw new Error('useStopwatchTimeContext must be used within a StopwatchProvider'); + } + return context; +}; + +export const useStopwatchStepContext = () => { + const context = useContext(StopwatchStepContext); + if (!context) { + throw new Error('useStopwatchStepContext must be used within a StopwatchProvider'); + } + + return context; +}; diff --git a/src/app/mission/[id]/stopwatch/StopwatchSection.tsx b/src/app/mission/[id]/stopwatch/StopwatchSection.tsx new file mode 100644 index 00000000..621eff4b --- /dev/null +++ b/src/app/mission/[id]/stopwatch/StopwatchSection.tsx @@ -0,0 +1,36 @@ +'use client'; + +import Stopwatch from '@/components/Stopwatch/Stopwatch'; +import { css } from '@styled-system/css'; + +import { useStopwatchStepContext, useStopwatchTimeContext } from './Stopwatch.context'; + +interface Props { + missionName: string; +} + +function StopwatchSection({ missionName }: Props) { + const { step } = useStopwatchStepContext(); + const { minutes, seconds, time } = useStopwatchTimeContext(); + + const stepper = time < 60 ? 0 : Math.floor(time / 60 / 10); + + return ( +
+ +
+ ); +} + +export default StopwatchSection; + +const opacityAnimation = css({ + animation: 'fadeIn .7s', +}); diff --git a/src/app/mission/[id]/stopwatch/TextSection.tsx b/src/app/mission/[id]/stopwatch/TextSection.tsx new file mode 100644 index 00000000..2d656582 --- /dev/null +++ b/src/app/mission/[id]/stopwatch/TextSection.tsx @@ -0,0 +1,45 @@ +import { Fragment } from 'react'; +import { STOPWATCH_STATUS_LABEL } from '@/hooks/mission/stopwatch/useStopwatchStatus'; +import { css, cx } from '@styled-system/css'; + +import { useStopwatchStepContext } from './Stopwatch.context'; + +function TextSection() { + const { step } = useStopwatchStepContext(); + + const stepLabel = STOPWATCH_STATUS_LABEL[step]; + + return ( +
+
+

{stepLabel.title}

+

+ {stepLabel.desc.split('\n').map((text) => ( + + {text} +
+
+ ))} +

+
+
+ ); +} + +export default TextSection; + +const containerCss = css({ + padding: '24px 16px', +}); +const titleCss = css({ color: 'text.primary', textStyle: 'title2' }); +const descCss = css({ + color: 'text.secondary', + textStyle: 'body4', + marginTop: '8px', + marginBottom: '76px', + minHeight: '40px', +}); + +const opacityAnimation = css({ + animation: 'fadeIn .7s', +}); diff --git a/src/app/mission/[id]/stopwatch/index.hooks.ts b/src/app/mission/[id]/stopwatch/index.hooks.ts index f651e664..8bee563a 100644 --- a/src/app/mission/[id]/stopwatch/index.hooks.ts +++ b/src/app/mission/[id]/stopwatch/index.hooks.ts @@ -1,8 +1,18 @@ import { useCallback, useEffect } from 'react'; +import { useRouter } from 'next/navigation'; +import { useRecordTime } from '@/apis'; +import { isSeverError } from '@/apis/instance.api'; import { EVENT_LOG_CATEGORY, EVENT_LOG_NAME } from '@/constants/eventLog'; +import { ROUTER } from '@/constants/router'; import useInterval from '@/hooks/useInterval'; import { eventLogger } from '@/utils'; -import { setProgressMissionTime, setProgressMissionTime2 } from '@/utils/storage/progressMission'; +import { + getProgressMissionStartTimeToStorage, + removeProgressMissionData, + setProgressMissionTime, + setProgressMissionTime2, +} from '@/utils/storage/progressMission'; +import { formatDate, formatMMSS } from '@/utils/time'; export function useUnloadAction(time: number, missionId: string) { const onSaveTime = useCallback(() => { @@ -75,3 +85,54 @@ export function useCustomBack(customBack: () => void) { }; }, []); } + +/** + * 미션 임시 인증 (타이머 시간 서버에 저장) + * @param missionId + * @param second 타이머 종료 시간 + * @returns {isSubmitLoading, onSubmit} - isSubmitLoading: 제출 중인지 여부, onSubmit: 제출 함수 + */ +export const useSubmit = ({ missionId, second }: { missionId: string; second: number }) => { + const router = useRouter(); + + const { formattedMinutes, formattedSeconds } = formatMMSS(second); + + const { mutate, isPending: isSubmitLoading } = useRecordTime({ + onSuccess: (response) => { + const missionRecordId = String(response.missionId); + router.replace(ROUTER.RECORD.CREATE(missionRecordId)); + eventLogger.logEvent('api/record-time', 'stopwatch', { missionRecordId }); + removeProgressMissionData(); + }, + onError: (error) => { + if (isSeverError(error)) { + if (error.response.data.data.errorClassName === 'MISSION_RECORD_ALREADY_EXISTS_TODAY') { + removeProgressMissionData(); + router.replace(ROUTER.HOME); + } + } + }, + }); + + const onSubmit = useCallback(async () => { + const startTimeString = getProgressMissionStartTimeToStorage(missionId); + if (!startTimeString) return; + + const startTime = new Date(startTimeString); + const startTimeFormatted = formatDate(startTime); + const finishTimeFormatted = formatDate(new Date()); + + mutate({ + missionId: missionId, + startedAt: startTimeFormatted, + finishedAt: finishTimeFormatted, + durationMin: Number(formattedMinutes), + durationSec: Number(formattedSeconds), + }); + }, [formattedMinutes, formattedSeconds, missionId, mutate]); + + return { + isSubmitLoading, + onSubmit, + }; +}; diff --git a/src/app/mission/[id]/stopwatch/modals.tsx b/src/app/mission/[id]/stopwatch/modals.tsx deleted file mode 100644 index c2334f67..00000000 --- a/src/app/mission/[id]/stopwatch/modals.tsx +++ /dev/null @@ -1,67 +0,0 @@ -import Dialog from '@/components/Dialog/Dialog'; -import { EVENT_LOG_CATEGORY, EVENT_LOG_NAME } from '@/constants/eventLog'; -import { eventLogger } from '@/utils'; - -interface DialogProps { - isOpen: boolean; - onClose: VoidFunction; - onConfirm: VoidFunction; - onCancel: VoidFunction; - logData?: Record; -} - -export function FinalDialog({ onConfirm, logData, ...props }: DialogProps) { - const _onConfirm = () => { - logData && eventLogger.logEvent(EVENT_LOG_NAME.STOPWATCH.CLICK_FINISH, EVENT_LOG_CATEGORY.STOPWATCH); - onConfirm(); - }; - return ( - - ); -} - -export function BackDialog({ onConfirm, logData, ...props }: DialogProps) { - const _onConfirm = () => { - logData && eventLogger.logEvent(EVENT_LOG_NAME.STOPWATCH.CLICK_BACK, EVENT_LOG_CATEGORY.STOPWATCH); - onConfirm(); - }; - - return ( - - ); -} - -export function MidOutDialog({ onConfirm, logData, ...props }: DialogProps) { - const _onConfirm = () => { - logData && eventLogger.logEvent(EVENT_LOG_NAME.STOPWATCH.CLICK_MID_OUT, EVENT_LOG_CATEGORY.STOPWATCH); - onConfirm(); - }; - - return ( - - ); -} diff --git a/src/app/mission/[id]/stopwatch/page.tsx b/src/app/mission/[id]/stopwatch/page.tsx index 30054bb2..773bcb42 100644 --- a/src/app/mission/[id]/stopwatch/page.tsx +++ b/src/app/mission/[id]/stopwatch/page.tsx @@ -1,334 +1,28 @@ 'use client'; -import { Fragment, useEffect, useState } from 'react'; -import { useParams, useRouter } from 'next/navigation'; -import { isSeverError } from '@/apis/instance.api'; +import { useParams } from 'next/navigation'; import { useGetMissionDetailNoSuspense } from '@/apis/mission'; -import { useRecordTime } from '@/apis/record'; -import { useCustomBack, useRecordMidTime, useUnloadAction } from '@/app/mission/[id]/stopwatch/index.hooks'; -import { BackDialog, FinalDialog, MidOutDialog } from '@/app/mission/[id]/stopwatch/modals'; -import Button from '@/components/Button/Button'; -import Header from '@/components/Header/Header'; -import Loading from '@/components/Loading'; -import Stopwatch from '@/components/Stopwatch/Stopwatch'; -import { EVENT_LOG_CATEGORY, EVENT_LOG_NAME } from '@/constants/eventLog'; -import { MISSION_CATEGORY_LABEL } from '@/constants/mission'; -import { ROUTER } from '@/constants/router'; -import useStopwatch from '@/hooks/mission/stopwatch/useStopwatch'; -import useStopwatchStatus from '@/hooks/mission/stopwatch/useStopwatchStatus'; -import useModal from '@/hooks/useModal'; -import { eventLogger } from '@/utils'; -import { - checkPrevProgressMission, - getProgressMissionStartTimeToStorage, - removeProgressMissionData, - setMissionData, - setMissionTimeStack, -} from '@/utils/storage/progressMission'; -import { formatDate } from '@/utils/time'; -import { css, cx } from '@styled-system/css'; + +import ButtonSection from './ButtonSection'; +import StopwatchHeader from './Header'; +import StopwatchProvider from './Stopwatch.context'; +import StopwatchSection from './StopwatchSection'; +import TextSection from './TextSection'; export default function StopwatchPage() { const params = useParams(); const missionId = params.id as string; - const router = useRouter(); - const { data: missionData } = useGetMissionDetailNoSuspense(missionId); - const category = missionData?.category ? MISSION_CATEGORY_LABEL[missionData?.category].label : ''; + // const category = missionData?.category ? MISSION_CATEGORY_LABEL[missionData?.category].label : ''; const missionName = missionData?.name ?? ''; - const { step, prevStep, stepLabel, onNextStep } = useStopwatchStatus(); - const { - seconds, - minutes, - stepper, - isFinished, - isPending: isStopwatchPending, - } = useStopwatch(step, missionId, onNextStep); - const [isMoveLoading, setIsMoveLoading] = useState(false); - - const time = Number(minutes) * 60 + Number(seconds); - const logData = { - category, - finishTime: time, - }; - - const { isOpen: isFinalModalOpen, openModal: openFinalModal, closeModal: closeFinalModal } = useModal(); - const { isOpen: isBackModalOpen, openModal: openBackModal, closeModal: closeBackModal } = useModal(); - const { isOpen: isMidOutModalOpen, openModal: openMidOutModal, closeModal: closeMidOutModal } = useModal(); - const { - isOpen: isBackMidOutModalOpen, - openModal: openBackMidOutModal, - closeModal: closeBackMidOutModal, - } = useModal(); - - useCustomBack(() => { - onNextStep('stop'); - openBackMidOutModal(); - }); - - useRecordMidTime(time, missionId); - useUnloadAction(time, missionId); - - // isError 처리 어떻게 할것인지? - const { mutate, isPending: isSubmitLoading } = useRecordTime({ - onSuccess: (response) => { - const missionRecordId = String(response.missionId); - router.replace(ROUTER.RECORD.CREATE(missionRecordId)); - eventLogger.logEvent('api/record-time', 'stopwatch', { missionRecordId }); - - setIsMoveLoading(true); - removeProgressMissionData(); - }, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - onError: (error) => { - if (isSeverError(error)) { - if (error.response.data.data.errorClassName === 'MISSION_RECORD_ALREADY_EXISTS_TODAY') { - removeProgressMissionData(); - router.replace(ROUTER.HOME); - } - } - }, - }); - - // TODO: 끝내기 후 로직 추가 - const onSubmit = async () => { - const startTimeString = getProgressMissionStartTimeToStorage(missionId); - if (!startTimeString) return; - - const startTime = new Date(startTimeString); - const startTimeFormatted = formatDate(startTime); - const finishTimeFormatted = formatDate(new Date()); - - mutate({ - missionId: missionId, - startedAt: startTimeFormatted, - finishedAt: finishTimeFormatted, - durationMin: Number(minutes), - durationSec: Number(seconds), - }); - }; - - const onFinishButtonClick = () => { - onNextStep('stop'); - - // 10분 지나기 전 끝내기 눌렀을 때 - if (Number(minutes) < 10) { - eventLogger.logEvent( - EVENT_LOG_NAME.STOPWATCH.CLICK_FINISH_BUTTON_BEFORE_10MM, - EVENT_LOG_CATEGORY.STOPWATCH, - logData, - ); - openMidOutModal(); - return; - } - - eventLogger.logEvent(EVENT_LOG_NAME.STOPWATCH.CLICK_FINISH_BUTTON, EVENT_LOG_CATEGORY.STOPWATCH, logData); - openFinalModal(); - }; - - // 뒤로가기 버튼 눌렀을 때 - const onExit = () => { - router.replace(ROUTER.MISSION.DETAIL(missionId)); - removeProgressMissionData(); - }; - - const onFinish = () => { - // TODO: 끝내기 로직 추가 - // 이쪽에 로딩 추가 필요 - onSubmit(); - }; - - const onAutoFinish = () => { - eventLogger.logEvent(EVENT_LOG_NAME.STOPWATCH.CLICK_AUTO_FINISH, EVENT_LOG_CATEGORY.STOPWATCH, { - category, - finishTime: Number(minutes) * 60 + Number(seconds), - }); - onSubmit(); - }; - - const onCancel = () => { - eventLogger.logEvent(EVENT_LOG_NAME.STOPWATCH.CLICK_CANCEL, EVENT_LOG_CATEGORY.STOPWATCH, { - category, - finishTime: Number(minutes) * 60 + Number(seconds), - }); - onNextStep(prevStep); - }; - - const onStop = () => { - eventLogger.logEvent(EVENT_LOG_NAME.STOPWATCH.CLICK_STOP, EVENT_LOG_CATEGORY.STOPWATCH, { - category, - stopTime: Number(minutes) * 60 + Number(seconds), - }); - onNextStep('stop'); - setMissionTimeStack(missionId, 'stop'); - }; - - const onStart = () => { - onNextStep('progress'); - - // 이전 미션 기록 삭제 - 강제 접근 이슈 - checkPrevProgressMission(missionId); - setMissionTimeStack(missionId, 'start'); - - // 중도 재시작 - if (time > 0) { - eventLogger.logEvent(EVENT_LOG_NAME.STOPWATCH.CLICK_RESTART, EVENT_LOG_CATEGORY.STOPWATCH); - return; - } - // 초기시작 - eventLogger.logEvent(EVENT_LOG_NAME.STOPWATCH.CLICK_START, EVENT_LOG_CATEGORY.STOPWATCH); - setMissionData(missionId); - }; - - const onBackAction = () => { - onNextStep('stop'); - openBackModal(); - }; - - const onBackMidModalClose = () => { - closeBackMidOutModal(); - history.pushState({}, '', location.href); - onNextStep(prevStep); - }; - - useEffect(() => { - if (isFinished) { - onAutoFinish(); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [isFinished]); - return ( - <> - {isSubmitLoading || (isMoveLoading && )} -
-
-
-

{stepLabel.title}

-

- {stepLabel.desc.split('\n').map((text) => ( - - {text} -
-
- ))} -

-
-
- -
-
- {step === 'ready' && ( -
- -
- )} - {step === 'progress' && ( - <> - - - - )} - {step === 'stop' && ( - <> - - - - )} -
- - - - -
- + + + + + + ); } - -const containerCss = css({ - padding: '24px 16px', -}); - -const titleCss = css({ color: 'text.primary', textStyle: 'title2' }); -const descCss = css({ - color: 'text.secondary', - textStyle: 'body4', - marginTop: '8px', - marginBottom: '76px', - minHeight: '40px', -}); - -const buttonContainerCss = css({ - margin: '28px auto', - display: 'flex', - justifyContent: 'center', - gap: '12px', -}); - -const opacityAnimation = css({ - animation: 'fadeIn .7s', -}); - -const fixedButtonContainerCss = css({ - position: 'fixed', - left: '16px', - right: '16px', - bottom: '16px', - width: '100%', - maxWidth: 'calc(475px - 48px)', - margin: '0 auto', - '@media (max-width: 475px)': { - maxWidth: 'calc(100vw - 48px)', - }, -}); diff --git a/src/app/record/[id]/detail/MissionRecordDetail.tsx b/src/app/record/[id]/detail/MissionRecordDetail.tsx index 55a66217..8a1abb45 100644 --- a/src/app/record/[id]/detail/MissionRecordDetail.tsx +++ b/src/app/record/[id]/detail/MissionRecordDetail.tsx @@ -57,7 +57,8 @@ const missionHistorySectionCss = css({ const textSecondaryColorCss = css({ color: 'text.secondary', - whiteSpace: 'pre', + whiteSpace: 'pre-wrap', + wordBreak: 'break-word', }); const textTertiaryColorCss = css({ diff --git a/src/hooks/mission/stopwatch/useStopwatch.ts b/src/hooks/mission/stopwatch/useStopwatch.ts deleted file mode 100644 index 999ea688..00000000 --- a/src/hooks/mission/stopwatch/useStopwatch.ts +++ /dev/null @@ -1,100 +0,0 @@ -import { useEffect, useState } from 'react'; -import { useVisibilityStateVisible } from '@/app/mission/[id]/stopwatch/index.hooks'; -import { EVENT_LOG_CATEGORY, EVENT_LOG_NAME } from '@/constants/eventLog'; -import { eventLogger } from '@/utils'; -import { getPrevProgressMissionStatus, getProgressMissionTime } from '@/utils/storage/progressMission'; -import { formatMMSS } from '@/utils/time'; - -import { type StepType } from './useStopwatchStatus'; - -const INIT_SECONDS = 0; -const MAX_SECONDS = 3600; // max 1 hour - -const DEFAULT_MS = 1000; -const timerMs: number = Number(process.env.NEXT_PUBLIC_TIMER_MS ?? DEFAULT_MS); - -export default function useStopwatch(status: StepType, missionId: string, onNextStep?: (step: StepType) => void) { - const [second, setSecond] = useState(INIT_SECONDS); // 남은 시간 (단위: 초) - const [isPending, setIsPending] = useState(true); - const [isFinished, setIsFinished] = useState(false); - const { formattedMinutes, formattedSeconds } = formatMMSS(second); - - const stepper = second < 60 ? 0 : Math.floor(second / 60 / 10); - - useEffect(() => { - if (second >= MAX_SECONDS) { - setIsFinished(true); - return; - } - if (status === 'ready') return; - - let timer: NodeJS.Timeout; - - if (status === 'progress') { - timer = setInterval(() => { - setSecond((prev) => (prev >= MAX_SECONDS ? prev : prev + 1)); - - // 10분 넘으면 이벤트 기록 - second === 10 * 60 && recordTenMinuteEvent(missionId); - }, timerMs); - } - - return () => clearInterval(timer); - }, [second, status]); - - /** - * @description 진행되고 있던 미션 시간 데이터를 세팅합니다. - * @param {string} currentMissionId 현재 미션 아이디 - */ - const settingInitTime = (currentMissionId: string): void => { - const initSeconds = getProgressMissionTime(currentMissionId); - - if (initSeconds >= MAX_SECONDS) { - setSecond(MAX_SECONDS); - } else { - setSecond(initSeconds); - } - }; - - // 화면 visible 상태로 변경 시, 시간을 다시 세팅 - useVisibilityStateVisible(() => { - setIsPending(true); - settingInitTime(missionId); - setIsPending(false); - }); - - useEffect(() => { - // 진행되고 있던 미션이 있는지 확인합니다. - const flag = checkIsExistProgressMission(missionId); - if (!flag) { - setIsPending(false); - return; - } - - // 진행되고 있던 미션이 있는 경우, 시간을 세팅합니다. - settingInitTime(missionId); - - // 이전 상태가 있을 경우, 이전 상태로 이동합니다. - const prevStatus = getPrevProgressMissionStatus(missionId); - prevStatus && onNextStep?.(prevStatus); - - setIsPending(false); - }, [missionId]); - - return { minutes: formattedMinutes, seconds: formattedSeconds, stepper, isFinished, isPending }; -} - -const recordTenMinuteEvent = (missionId: string) => { - eventLogger.logEvent(EVENT_LOG_NAME.STOPWATCH.COMPLETE_TEM_MINUTE, EVENT_LOG_CATEGORY.STOPWATCH, { - missionId, - }); -}; - -/** - * @description 진행되고 있던 미션이 있는지 확인합니다. - * @param missionId 미션 아이디 - * @returns {boolean} 진행되고 있던 미션이 있는지 여부 - */ -const checkIsExistProgressMission = (missionId: string) => { - return Boolean(getProgressMissionTime(missionId)); -}; diff --git a/src/hooks/mission/stopwatch/useStopwatchLogic.ts b/src/hooks/mission/stopwatch/useStopwatchLogic.ts new file mode 100644 index 00000000..907f6424 --- /dev/null +++ b/src/hooks/mission/stopwatch/useStopwatchLogic.ts @@ -0,0 +1,39 @@ +import { useEffect, useState } from 'react'; +import { StopwatchStep } from '@/hooks/mission/stopwatch/useStopwatchStatus'; + +const INIT_SECONDS = 0; +const MAX_SECONDS = 3600; // max 1 hour + +const DEFAULT_MS = 1000; +const timerMs: number = Number(process.env.NEXT_PUBLIC_TIMER_MS ?? DEFAULT_MS); + +interface UseStopwatchProps { + status: StopwatchStep; +} + +function useStopwatchLogic({ status }: UseStopwatchProps) { + const [second, setSecond] = useState(0); // 남은 시간 (단위: 초) + const [isFinished, setIsFinished] = useState(false); + + useEffect(() => { + if (second >= MAX_SECONDS) { + setIsFinished(true); + return; + } + if (status === StopwatchStep.ready) return; + + let timer: NodeJS.Timeout; + + if (status === StopwatchStep.progress) { + timer = setInterval(() => { + setSecond((prev) => (prev >= MAX_SECONDS ? prev : prev + 1)); + }, timerMs); + } + + return () => clearInterval(timer); + }, [second, status]); + + return { isFinished, second, setSecond }; +} + +export default useStopwatchLogic; diff --git a/src/hooks/mission/stopwatch/useStopwatchStatus.ts b/src/hooks/mission/stopwatch/useStopwatchStatus.ts index 29998e8b..1d959adf 100644 --- a/src/hooks/mission/stopwatch/useStopwatchStatus.ts +++ b/src/hooks/mission/stopwatch/useStopwatchStatus.ts @@ -1,8 +1,15 @@ import { useState } from 'react'; -export type StepType = 'ready' | 'progress' | 'stop'; +export enum StopwatchStep { + ready = 'ready', + progress = 'progress', + stop = 'stop', + finish = 'finish', +} + +// export type StepType = keyof typeof StopwatchStep; -const STOPWATCH_STATUS = { +export const STOPWATCH_STATUS_LABEL = { ready: { title: '준비 되셨나요?', desc: '시작 버튼을 눌러서 미션을 완성해 주세요!', @@ -15,15 +22,19 @@ const STOPWATCH_STATUS = { title: '잠시 멈췄어요', desc: '준비가 되면 다시 시작해주세요!', }, + finish: { + title: '', + desc: '', + }, } as const; -function useStopwatchStatus(initStatus?: StepType) { - const [step, setStep] = useState(initStatus ?? 'ready'); - const [prevStep, setPrevStep] = useState('ready'); +function useStopwatchStatus() { + const [step, setStep] = useState(StopwatchStep.ready); + const [prevStep, setPrevStep] = useState(StopwatchStep.ready); - const stepLabel = STOPWATCH_STATUS[step]; + const stepLabel = STOPWATCH_STATUS_LABEL[step]; - const onNextStep = (nextStep: StepType) => { + const onNextStep = (nextStep: StopwatchStep) => { setStep((prev) => { setPrevStep(prev); return nextStep; diff --git a/src/hooks/useModal.tsx b/src/hooks/useModal.tsx index f4072a4f..c670bcf6 100644 --- a/src/hooks/useModal.tsx +++ b/src/hooks/useModal.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useCallback, useState } from 'react'; interface UseModalProps { initialOpen?: boolean; @@ -13,13 +13,13 @@ export interface UseModalReturn { const useModal = ({ initialOpen = false }: UseModalProps = {}): UseModalReturn => { const [isOpen, setIsOpen] = useState(initialOpen); - const openModal = () => { + const openModal = useCallback(() => { setIsOpen(true); - }; + }, []); - const closeModal = () => { + const closeModal = useCallback(() => { setIsOpen(false); - }; + }, []); return { isOpen, diff --git a/src/utils/storage/progressMission.ts b/src/utils/storage/progressMission.ts index 3597a1d6..abd8d692 100644 --- a/src/utils/storage/progressMission.ts +++ b/src/utils/storage/progressMission.ts @@ -1,5 +1,6 @@ import { EVENT_LOG_CATEGORY, EVENT_LOG_NAME } from '@/constants/eventLog'; import { STORAGE_KEY } from '@/constants/storage'; +import { StopwatchStep } from '@/hooks/mission/stopwatch/useStopwatchStatus'; import { eventLogger } from '@/utils'; interface MissionData { @@ -96,17 +97,17 @@ export const getProgressMissionTime = (missionId: string): number => { return progressTime; }; -export const getPrevProgressMissionStatus = (missionId: string): 'ready' | 'progress' | 'stop' | undefined => { +export const getPrevProgressMissionStatus = (missionId: string): StopwatchStep | undefined => { const timeStack = localStorage.getItem(STORAGE_KEY.PROGRESS_MISSION.TIME_STACK(missionId)) || '[]'; const timeStackData = JSON.parse(timeStack); - if (!timeStackData || timeStackData.length === 0) return 'ready'; + if (!timeStackData || timeStackData.length === 0) return StopwatchStep.ready; const status = timeStackData[timeStackData.length - 1].status; - if (status === 'restart') return 'progress'; - if (status === 'stop') return 'stop'; + if (status === 'restart') return StopwatchStep.progress; + if (status === 'stop') return StopwatchStep.stop; - return 'progress'; + return StopwatchStep.progress; }; export const checkIsExistProgressMission = (missionId: string) => {