diff --git a/src/components/mission/InProgressMissionCard/InProgressMissionCard.tsx b/src/components/mission/InProgressMissionCard/InProgressMissionCard.tsx new file mode 100644 index 0000000..8ef4f1a --- /dev/null +++ b/src/components/mission/InProgressMissionCard/InProgressMissionCard.tsx @@ -0,0 +1,93 @@ +import { Camera, ChevronRight, Clock3, Flag } from "lucide-react"; + +import Button from "@/components/common/Button"; +import type { MissionAttempt } from "@/types/missionAttempt"; + +type Props = { + attempt: MissionAttempt; + onQuit: () => void; + onViewDetail: () => void; + onContinue: () => void; +}; + +export default function InProgressMissionCard({ + attempt, + onQuit, + onViewDetail, + onContinue, +}: Props) { + return ( +
+
+
+
+
+ + 진행 중 + +

+ + {formatStartedAt(attempt.startedAt)} +

+
+
+ +
+
+
+ +
+

+ {attempt.missionTitle} +

+
+ +

+ 미션 장소에서 위치와 사진을 인증해주세요 +

+
+ + + +
+ + +
+
+
+ ); +} + +function formatStartedAt(value: string) { + const date = new Date(value); + if (!Number.isFinite(date.getTime())) return "시작 시간 확인 불가"; + return `${new Intl.DateTimeFormat("ko-KR", { + month: "numeric", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + }).format(date)} 시작`; +} diff --git a/src/components/mission/InProgressMissionCard/InProgressMissionCardSkeleton.tsx b/src/components/mission/InProgressMissionCard/InProgressMissionCardSkeleton.tsx new file mode 100644 index 0000000..0317a86 --- /dev/null +++ b/src/components/mission/InProgressMissionCard/InProgressMissionCardSkeleton.tsx @@ -0,0 +1,25 @@ +export default function InProgressMissionCardSkeleton() { + return ( + + ); +} diff --git a/src/hooks/mutation/useQuitMissionAttempt.ts b/src/hooks/mutation/useQuitMissionAttempt.ts new file mode 100644 index 0000000..53d5525 --- /dev/null +++ b/src/hooks/mutation/useQuitMissionAttempt.ts @@ -0,0 +1,15 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; + +import { quitMissionAttempt } from "@/apis/missionAttempt"; + +export function useQuitMissionAttempt() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (attemptId: number) => quitMissionAttempt(attemptId), + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: ["missionAttempts"] }); + void queryClient.invalidateQueries({ queryKey: ["missions"] }); + }, + }); +} diff --git a/src/hooks/mutation/useStartMissionAttempt.ts b/src/hooks/mutation/useStartMissionAttempt.ts index b19c9be..ac9063b 100644 --- a/src/hooks/mutation/useStartMissionAttempt.ts +++ b/src/hooks/mutation/useStartMissionAttempt.ts @@ -1,9 +1,13 @@ -import { useMutation } from "@tanstack/react-query"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; import { startMissionAttempt } from "@/apis/missionAttempt"; export function useStartMissionAttempt() { + const queryClient = useQueryClient(); return useMutation({ mutationFn: (missionId: number) => startMissionAttempt(missionId), + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: ["missionAttempts"] }); + }, }); -} \ No newline at end of file +} diff --git a/src/hooks/mutation/useVerifyMissionArrival.ts b/src/hooks/mutation/useVerifyMissionArrival.ts index bf72550..fe1c093 100644 --- a/src/hooks/mutation/useVerifyMissionArrival.ts +++ b/src/hooks/mutation/useVerifyMissionArrival.ts @@ -1,9 +1,13 @@ -import { useMutation } from "@tanstack/react-query"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; import { verifyMissionArrival } from "@/apis/missionAttempt"; export function useVerifyMissionArrival() { + const queryClient = useQueryClient(); return useMutation({ mutationFn: verifyMissionArrival, + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: ["missionAttempts"] }); + }, }); -} \ No newline at end of file +} diff --git a/src/hooks/queries/useMissionAttempts.ts b/src/hooks/queries/useMissionAttempts.ts index 2ca55d4..a1875c6 100644 --- a/src/hooks/queries/useMissionAttempts.ts +++ b/src/hooks/queries/useMissionAttempts.ts @@ -6,6 +6,8 @@ export function useMissionAttempts() { return useQuery({ queryKey: ["missionAttempts"], queryFn: getMissionAttempts, - staleTime: 1000 * 60, + staleTime: 0, + refetchOnMount: "always", + retry: false, }); -} \ No newline at end of file +} diff --git a/src/pages/mission/InProgressMissionView.tsx b/src/pages/mission/InProgressMissionView.tsx new file mode 100644 index 0000000..ec7841d --- /dev/null +++ b/src/pages/mission/InProgressMissionView.tsx @@ -0,0 +1,202 @@ +import { useState } from "react"; +import { AlertTriangle, Flag, X } from "lucide-react"; +import { QuespotPageContent } from "@/layouts/QuespotPageLayout"; +import type { MissionAttempt } from "@/types/missionAttempt"; +import { useQuitMissionAttempt } from "@/hooks/mutation/useQuitMissionAttempt"; +import Button from "@/components/common/Button"; +import InProgressMissionCard from "@/components/mission/InProgressMissionCard/InProgressMissionCard"; +import InProgressMissionCardSkeleton from "@/components/mission/InProgressMissionCard/InProgressMissionCardSkeleton"; + +type Props = { + attempts: MissionAttempt[]; + isLoading: boolean; + isError: boolean; + error: Error | null; + onRetry: () => void; + onExplore: () => void; + onViewDetail: (attempt: MissionAttempt) => void; + onContinue: (attempt: MissionAttempt) => void; +}; + +export default function InProgressMissionView({ + attempts, + isLoading, + isError, + error, + onRetry, + onExplore, + onViewDetail, + onContinue, +}: Props) { + const [quitTarget, setQuitTarget] = useState(null); + const { + mutateAsync: quitAttempt, + isPending: isQuitting, + error: quitError, + reset: resetQuit, + } = useQuitMissionAttempt(); + + const closeQuitModal = () => { + if (isQuitting) return; + resetQuit(); + setQuitTarget(null); + }; + + const handleQuit = async () => { + if (!quitTarget) return; + + try { + await quitAttempt(quitTarget.attemptId); + setQuitTarget(null); + } catch {} + }; + + return ( + +
+
+

+ 지금 이어서 도전해보세요 +

+

+ 진행 중인 미션 +

+
+ {!isLoading && !isError ? ( + + {attempts.length}개 + + ) : null} +
+ + {isLoading ? ( +
+ +
+ ) : isError ? ( +
+

+ 미션을 불러오지 못했어요 +

+

+ {error?.message ?? "잠시 후 다시 시도해주세요."} +

+ +
+ ) : attempts.length === 0 ? ( +
+
+ +
+

+ 진행 중인 미션이 없어요 +

+

+ 마음에 드는 미션을 찾아 시작해보세요 +

+ +
+ ) : ( + attempts.map((attempt) => ( + onViewDetail(attempt)} + onContinue={() => onContinue(attempt)} + onQuit={() => { + resetQuit(); + setQuitTarget(attempt); + }} + /> + )) + )} + + {quitTarget ? ( +
{ + if (event.target === event.currentTarget) closeQuitModal(); + }} + > +
+
+ +
+ +
+ +
+

+ 미션을 포기할까요? +

+

+ ‘{quitTarget.missionTitle}’의 현재 진행 기록이 종료돼요. +

+ + {quitError ? ( +

+ {quitError.message || + "미션을 포기하지 못했어요. 다시 시도해주세요."} +

+ ) : null} + + + +
+
+ ) : null} +
+ ); +} diff --git a/src/pages/mission/MissionPage.tsx b/src/pages/mission/MissionPage.tsx index a90ca14..88a75e4 100644 --- a/src/pages/mission/MissionPage.tsx +++ b/src/pages/mission/MissionPage.tsx @@ -29,6 +29,8 @@ import type { UserMissionStatus, } from "@/types/mission"; import { PATH } from "@/routes/paths"; +import { useMissionAttempts } from "@/hooks/queries/useMissionAttempts"; +import InProgressMissionView from "./InProgressMissionView"; type LatLng = { lat: number; @@ -37,6 +39,8 @@ type LatLng = { type LocationStatus = "loading" | "success" | "error"; +type MissionTab = "explore" | "inProgress"; + type CategoryOption = { label: string; value?: MissionCategory; @@ -118,6 +122,18 @@ export default function MissionPage() { const searchKeyword = searchParams.get("keyword") ?? ""; const [keyword, setKeyword] = useState(searchKeyword); + const [activeTab, setActiveTab] = useState("explore"); + const { + data: attemptData, + isLoading: isAttemptsLoading, + isError: isAttemptsError, + error: attemptsError, + refetch: refetchAttempts, + } = useMissionAttempts(); + const attempts = + attemptData?.attempts.filter( + (attempt) => attempt.status === "IN_PROGRESS", + ) ?? []; useEffect(() => { setKeyword(searchKeyword); @@ -170,7 +186,13 @@ export default function MissionPage() { return () => { observer.disconnect(); }; - }, [fetchNextPage, hasNextPage, isFetchingNextPage]); + }, [ + activeTab, + missions.length, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + ]); const handleChangeCategory = (category?: MissionCategory) => { setSearchParams((current) => { @@ -202,11 +224,11 @@ export default function MissionPage() {

- 미션 탐색 + 미션

- 지금 주변에서 시작할 수 있는 미션이에요 + 새로운 장소를 발견하고 미션을 완료해보세요

@@ -216,106 +238,181 @@ export default function MissionPage() {
- - -
- {CATEGORY_OPTIONS.map((category) => { - const isSelected = selectedCategory === category.value; - - return ( - - ); - })} +
+ + +
- - - -
-
-

- 오늘의 추천 미션 -

- -

- 가까운 장소에서 미션을 시작해보세요 -

-
- - -
- -
-

- 미션 목록 -

-
- - {isLoading ? : null} - - {isError ? refetch()} /> : null} - {!isLoading && !isError && missions.length === 0 ? ( - - ) : null} - - {!isLoading && !isError && missions.length > 0 ? ( + {activeTab === "explore" ? ( <> -
- {missions.map((mission) => ( - handleMoveMissionDetail(mission)} - /> - ))} + + +
+ {CATEGORY_OPTIONS.map((category) => { + const isSelected = selectedCategory === category.value; + + return ( + + ); + })}
+ + ) : null} + -
+ {activeTab === "explore" ? ( + +
+
+

+ 오늘의 추천 미션 +

- {isFetchingNextPage ? ( -
- - 미션을 더 불러오는 중이에요 +

+ 가까운 장소에서 미션을 시작해보세요 +

+
+ + +
+ +
+

+ 미션 목록 +

+
+ + {isLoading ? : null} + + {isError ? refetch()} /> : null} + + {!isLoading && !isError && missions.length === 0 ? ( + + ) : null} + + {!isLoading && !isError && missions.length > 0 ? ( + <> +
+ {missions.map((mission) => ( + handleMoveMissionDetail(mission)} + /> + ))}
- ) : null} - {!hasNextPage ? ( -

- 모든 미션을 불러왔어요 -

- ) : null} - - ) : null} -
+
+ + {isFetchingNextPage ? ( +
+ + 미션을 더 불러오는 중이에요 +
+ ) : null} + + {!hasNextPage ? ( +

+ 모든 미션을 불러왔어요 +

+ ) : null} + + ) : null} + + ) : ( + void refetchAttempts()} + onExplore={() => setActiveTab("explore")} + onViewDetail={(attempt) => + navigate( + PATH.MISSION_DETAIL.replace( + ":missionId", + String(attempt.missionId), + ), + ) + } + onContinue={(attempt) => + navigate(PATH.MISSION_VERIFY, { + state: { + missionId: attempt.missionId, + attemptId: attempt.attemptId, + missionTitle: attempt.missionTitle, + }, + }) + } + /> + )} ); } @@ -426,8 +523,8 @@ function MissionCard({ mission, onClick }: MissionCardProps) {
- 약{" "} - {mission.estimatedMinutes}분 + 약 {mission.estimatedMinutes} + 분 diff --git a/src/pages/mission/verify/VerifyLoadingPage.tsx b/src/pages/mission/verify/VerifyLoadingPage.tsx index 732b48f..2feff26 100644 --- a/src/pages/mission/verify/VerifyLoadingPage.tsx +++ b/src/pages/mission/verify/VerifyLoadingPage.tsx @@ -20,6 +20,9 @@ type VerifyLoadingPageState = { missionTitle?: string; isDemoMode?: boolean; isSuccess?: boolean; + failureReason?: "arrival" | "photo"; + distanceMeters?: number; + radiusMeters?: number; }; const steps = [ @@ -47,9 +50,10 @@ export default function VerifyLoadingPage() { const state = location.state as VerifyLoadingPageState | null; const [currentStep, setCurrentStep] = useState(0); + const visibleSteps = steps; useEffect(() => { - if (currentStep >= steps.length) { + if (currentStep >= visibleSteps.length) { navigate(PATH.MISSION_VERIFY_RESULT, { replace: true, state: { @@ -59,6 +63,9 @@ export default function VerifyLoadingPage() { missionTitle: state?.missionTitle, isDemoMode: state?.isDemoMode, isSuccess: state?.isSuccess ?? true, + failureReason: state?.failureReason, + distanceMeters: state?.distanceMeters, + radiusMeters: state?.radiusMeters, }, }); @@ -85,7 +92,7 @@ export default function VerifyLoadingPage() {

- {steps.map((step, index) => { + {visibleSteps.map((step, index) => { const Icon = step.icon; const isCompleted = index < currentStep; diff --git a/src/pages/mission/verify/VerifyPage.tsx b/src/pages/mission/verify/VerifyPage.tsx index 787252d..b948ba5 100644 --- a/src/pages/mission/verify/VerifyPage.tsx +++ b/src/pages/mission/verify/VerifyPage.tsx @@ -92,6 +92,22 @@ export default function VerifyPage() { latitude: currentLocation.lat, longitude: currentLocation.lng, }); + + if (!arrivalResult.success) { + navigate(PATH.MISSION_VERIFY_LOADING, { + state: { + missionId: state?.missionId, + attemptId, + mission, + missionTitle, + isSuccess: false, + failureReason: "arrival", + distanceMeters: arrivalResult.distanceMeters, + radiusMeters: arrivalResult.radiusMeters, + }, + }); + return; + } } catch (error) { console.error(error); alert("GPS 도착 인증에 실패했어요. 위치 권한을 확인해주세요."); @@ -314,7 +330,11 @@ export default function VerifyPage() { ) : ( )} diff --git a/src/pages/mission/verify/VerifyResultPage.tsx b/src/pages/mission/verify/VerifyResultPage.tsx index b9ff6a5..4bb6370 100644 --- a/src/pages/mission/verify/VerifyResultPage.tsx +++ b/src/pages/mission/verify/VerifyResultPage.tsx @@ -1,6 +1,6 @@ import { useMemo, useState } from "react"; import { useLocation, useNavigate } from "react-router-dom"; -import { ArrowRight, Lightbulb, Loader2, NotebookPen } from "lucide-react"; +import { ArrowRight, Lightbulb, Loader2, MapPin, NotebookPen, X } from "lucide-react"; import Button from "@/components/common/Button"; import { PATH } from "@/routes/paths"; @@ -17,6 +17,9 @@ type VerifyResultPageState = { missionTitle?: string; isDemoMode?: boolean; isSuccess?: boolean; + failureReason?: "arrival" | "photo"; + distanceMeters?: number; + radiusMeters?: number; }; export default function VerifyResultPage() { @@ -25,8 +28,11 @@ export default function VerifyResultPage() { const state = location.state as VerifyResultPageState | null; const attemptId = state?.attemptId ?? null; + const shouldFetchResult = Boolean(attemptId) && state?.isSuccess !== false; - const { data, isLoading, isError } = useMissionAttemptResult(attemptId); + const { data, isLoading, isError } = useMissionAttemptResult( + shouldFetchResult ? attemptId : null, + ); const [previewStatus, setPreviewStatus] = useState<"success" | "fail">( state?.isSuccess === false ? "fail" : "success", @@ -49,14 +55,14 @@ export default function VerifyResultPage() { const isSuccess = previewStatus === "success"; - if (attemptId && isLoading) { + if (shouldFetchResult && isLoading) { return ; } return (
-
+ {/*
-
+
*/} {isError && attemptId ? (
- 완료 결과 조회 API 응답을 불러오지 못해서 임시 결과를 표시하고 - 있어요. + 완료 결과 조회 API 응답을 불러오지 못해서 임시 결과를 표시하고 있어요.
) : null} @@ -196,6 +201,9 @@ type FailResultProps = { }; function FailResult({ navigate, state }: FailResultProps) { + const isArrivalFailure = state?.failureReason === "arrival"; + const [isHintOpen, setIsHintOpen] = useState(false); + return (
우는 퀘스티 @@ -205,10 +213,20 @@ function FailResult({ navigate, state }: FailResultProps) {

인증 실패

- 구도가 다르거나 장소가 맞지 않아요. -
- 재촬영 기회가 2회 남아 - 있어요. + {isArrivalFailure ? ( + <> + 미션 장소와 현재 위치가 일치하지 않아요. +
+ 장소에 더 가까이 이동한 뒤 다시 시도해주세요. + + ) : ( + <> + 구도가 다르거나 장소가 맞지 않아요. +
+ 재촬영 기회가 2회 남아 + 있어요. + + )}

@@ -216,8 +234,17 @@ function FailResult({ navigate, state }: FailResultProps) {

❌ 실패 이유

    -
  • · 간판이 사진에 포함되지 않았어요
  • -
  • · GPS 위치와 사진 위치가 달라요
  • + {isArrivalFailure ? ( + <> +
  • · 인증 가능한 반경 밖에 있어요
  • +
  • · 위치 권한과 GPS 정확도를 확인해주세요
  • + + ) : ( + <> +
  • · 간판이 사진에 포함되지 않았어요
  • +
  • · GPS 위치와 사진 위치가 달라요
  • + + )}
@@ -240,7 +267,11 @@ function FailResult({ navigate, state }: FailResultProps) {
- @@ -255,6 +286,53 @@ function FailResult({ navigate, state }: FailResultProps) {
+ + {isHintOpen ? ( +
{ + if (event.target === event.currentTarget) setIsHintOpen(false); + }} + > +
+
+ +
+
+ +
+

+ 조금 더 가까이 이동해주세요 +

+

+ 미션 장소로부터 {formatMeters(state?.distanceMeters)} 떨어져 있어요. +
+ {formatMeters(state?.radiusMeters)} 안으로 이동한 뒤 다시 시도해주세요. +

+ +
+
+ ) : null} ); -} \ No newline at end of file +} + +function formatMeters(value?: number) { + return Number.isFinite(value) ? `${Math.round(value as number)}m` : "거리 정보 없음"; +} diff --git a/src/types/missionAttempt.ts b/src/types/missionAttempt.ts index c43012b..9ab8164 100644 --- a/src/types/missionAttempt.ts +++ b/src/types/missionAttempt.ts @@ -12,7 +12,7 @@ export type MissionAttempt = { status: MissionAttemptStatus; startedAt: string; completedAt: string | null; - earnedPoint: number; + earnedPoint: number | null; }; export type MissionArrivalResult = {