Skip to content
Merged
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
Original file line number Diff line number Diff line change
@@ -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 (
<article className="mt-[16px] overflow-hidden rounded-[22px] border border-[#DCEEFF] bg-white shadow-[0_8px_24px_rgba(51,111,161,0.10)]">
<div className="relative overflow-hidden bg-gradient-to-br from-[#BCE4FF] via-[#DCF2FF] to-[#E8FBF3] px-[20px] py-[24px]">
<div className="absolute -right-[16px] -top-[28px] h-[120px] w-[120px] rounded-full bg-white/35" />
<div className="relative flex items-center justify-between gap-[12px]">
<div className="min-w-0">
<span className="inline-flex rounded-full bg-white/85 px-[11px] py-[6px] text-[11px] font-black text-[#388FD0]">
진행 중
</span>
<p className="m-0 mt-[13px] flex items-center gap-[5px] text-[12px] font-bold text-[#5D6A7D]">
<Clock3 size={14} className="shrink-0" />
{formatStartedAt(attempt.startedAt)}
</p>
</div>
<div className="grid h-[64px] w-[64px] shrink-0 place-items-center rounded-[20px] border border-white/70 bg-white/65 text-[#4AA9EB]">
<Flag size={30} strokeWidth={2.2} />
</div>
</div>
</div>

<div className="px-[18px] pb-[18px] pt-[19px]">
<h3 className="m-0 break-keep text-[19px] font-black leading-[27px] text-[#1C1C3A]">
{attempt.missionTitle}
</h3>
<div className="mt-[17px] flex items-center gap-[10px] rounded-[14px] bg-[#F7FAFE] px-[14px] py-[14px]">
<Camera size={20} className="shrink-0 text-[#5BB5F8]" />
<p className="m-0 text-[12px] font-medium leading-[19px] text-[#6F7B8D]">
미션 장소에서 위치와 사진을 인증해주세요
</p>
</div>

<Button
variant="secondary"
size="md"
onClick={onViewDetail}
className="mt-[10px] !justify-between !rounded-[13px] !border !border-[#E1EDFA] !bg-white !px-[14px] !text-[#5D6A7D] active:!bg-[#F7FAFE]"
icon={null}
>
<span>미션 상세 확인하기</span>
<ChevronRight size={16} className="text-[#8B97A7]" />
</Button>

<div className="mt-[16px] flex gap-[9px]">
<Button
variant="redSecondary"
size="md"
onClick={onQuit}
className="!h-[50px] !w-auto shrink-0 !rounded-[16px] !border !border-[#FFDADD] !bg-[#FFF7F8] !px-[16px] !text-[13px] !text-[#F05B67]"
>
포기하기
</Button>
<Button
variant="primary"
size="md"
onClick={onContinue}
className="!h-[50px] min-w-0 flex-1 !rounded-[16px] !text-[14px] shadow-[0_7px_16px_rgba(91,181,248,0.27)]"
>
인증 계속하기 <ChevronRight size={17} strokeWidth={2.6} />
</Button>
</div>
</div>
</article>
);
}

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)} 시작`;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
export default function InProgressMissionCardSkeleton() {
return (
<article
aria-hidden="true"
className="mt-[16px] animate-pulse overflow-hidden rounded-[22px] border border-[#DCEEFF] bg-white shadow-[0_8px_24px_rgba(51,111,161,0.06)] motion-reduce:animate-none"
>
<div className="flex h-[112px] items-center justify-between bg-[#E7F3FB] px-[20px]">
<div>
<div className="h-[26px] w-[58px] rounded-full bg-white/80" />
<div className="mt-[13px] h-[12px] w-[126px] rounded bg-white/65" />
</div>
<div className="h-[64px] w-[64px] rounded-[20px] bg-white/65" />
</div>
<div className="px-[18px] pb-[18px] pt-[19px]">
<div className="h-[20px] w-4/5 rounded-[7px] bg-slate-200" />
<div className="mt-[17px] h-[48px] rounded-[14px] bg-slate-100" />
<div className="mt-[10px] h-[42px] rounded-[13px] bg-slate-100" />
<div className="mt-[16px] flex gap-[9px]">
<div className="h-[50px] w-[82px] rounded-[16px] bg-slate-100" />
<div className="h-[50px] flex-1 rounded-[16px] bg-sky-100" />
</div>
</div>
</article>
);
}
15 changes: 15 additions & 0 deletions src/hooks/mutation/useQuitMissionAttempt.ts
Original file line number Diff line number Diff line change
@@ -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"] });
},
});
}
8 changes: 6 additions & 2 deletions src/hooks/mutation/useStartMissionAttempt.ts
Original file line number Diff line number Diff line change
@@ -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"] });
},
});
}
}
8 changes: 6 additions & 2 deletions src/hooks/mutation/useVerifyMissionArrival.ts
Original file line number Diff line number Diff line change
@@ -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"] });
},
});
}
}
6 changes: 4 additions & 2 deletions src/hooks/queries/useMissionAttempts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ export function useMissionAttempts() {
return useQuery({
queryKey: ["missionAttempts"],
queryFn: getMissionAttempts,
staleTime: 1000 * 60,
staleTime: 0,
refetchOnMount: "always",
retry: false,
});
}
}
202 changes: 202 additions & 0 deletions src/pages/mission/InProgressMissionView.tsx
Original file line number Diff line number Diff line change
@@ -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<MissionAttempt | null>(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 (
<QuespotPageContent className="bg-[#F4F8FF] px-[16px] pb-[28px] pt-[18px]">
<div className="flex items-end justify-between">
<div>
<p className="m-0 text-[12px] font-black leading-[18px] text-[#5BB5F8]">
지금 이어서 도전해보세요
</p>
<h2 className="m-0 mt-[2px] text-[21px] font-black leading-[29px] text-[#1C1C3A]">
진행 중인 미션
</h2>
</div>
{!isLoading && !isError ? (
<span className="text-[12px] font-bold text-[#A2A9B2]">
{attempts.length}개
</span>
) : null}
</div>

{isLoading ? (
<div role="status" aria-label="진행 중인 미션을 불러오는 중">
<InProgressMissionCardSkeleton />
</div>
) : isError ? (
<div
role="alert"
className="mt-[18px] rounded-[20px] bg-white px-[20px] py-[32px] text-center"
>
<h3 className="m-0 text-[16px] font-black text-[#1C1C3A]">
미션을 불러오지 못했어요
</h3>
<p className="mt-[10px] break-keep text-[13px] leading-[20px] text-[#6F7B8D]">
{error?.message ?? "잠시 후 다시 시도해주세요."}
</p>
<Button
size="sm"
onClick={onRetry}
className="mx-auto mt-[8px] !px-[20px] !py-[12px] !text-[13px]"
>
다시 불러오기
</Button>
</div>
) : attempts.length === 0 ? (
<div className="mt-[18px] rounded-[20px] bg-white px-[20px] py-[42px] text-center">
<div className="mx-auto grid h-[64px] w-[64px] place-items-center rounded-[20px] bg-[#EAF5FF] text-[#5BB5F8]">
<Flag size={28} />
</div>
<h3 className="m-0 mt-[18px] text-[17px] font-black text-[#1C1C3A]">
진행 중인 미션이 없어요
</h3>
<p className="mt-[8px] text-[13px] leading-[20px] text-[#A2A9B2]">
마음에 드는 미션을 찾아 시작해보세요
</p>
<Button
size="sm"
onClick={onExplore}
className="mx-auto mt-[10px] !px-[22px] !py-[12px] !text-[13px]"
>
미션 탐색하기
</Button>
</div>
) : (
attempts.map((attempt) => (
<InProgressMissionCard
key={attempt.attemptId}
attempt={attempt}
onViewDetail={() => onViewDetail(attempt)}
onContinue={() => onContinue(attempt)}
onQuit={() => {
resetQuit();
setQuitTarget(attempt);
}}
/>
))
)}

{quitTarget ? (
<div
className="fixed inset-y-0 left-1/2 z-[100] flex w-full max-w-[430px] -translate-x-1/2 items-center justify-center bg-[#1C1C3A]/40 px-[24px] backdrop-blur-[2px]"
role="presentation"
onMouseDown={(event) => {
if (event.target === event.currentTarget) closeQuitModal();
}}
>
<section
role="dialog"
aria-modal="true"
aria-labelledby="quit-mission-title"
className="w-full max-w-[330px] rounded-[24px] bg-white px-[20px] pb-[22px] pt-[18px] text-center shadow-[0_18px_40px_rgba(8,37,95,0.28)]"
>
<div className="flex justify-end">
<Button
size="sm"
variant="secondary"
onClick={closeQuitModal}
disabled={isQuitting}
aria-label="닫기"
className="!h-[34px] !w-[34px] !rounded-full !bg-[#F4F8FF] !p-0 !text-[#6F7B8D] disabled:opacity-40"
>
<X size={18} />
</Button>
</div>

<div className="mx-auto mt-[4px] grid h-[58px] w-[58px] place-items-center rounded-[18px] bg-[#FFF2E8] text-[#F59E0B]">
<AlertTriangle size={28} strokeWidth={2.3} />
</div>
<h2
id="quit-mission-title"
className="m-0 mt-[16px] text-center text-[19px] font-black text-[#1C1C3A]"
>
미션을 포기할까요?
</h2>
<p className="mx-auto mt-[8px] max-w-[300px] break-keep text-center text-[13px] leading-[20px] text-[#6F7B8D]">
‘{quitTarget.missionTitle}’의 현재 진행 기록이 종료돼요.
</p>

{quitError ? (
<p
role="alert"
className="mt-[12px] rounded-[12px] bg-[#FFF1F2] px-[12px] py-[10px] text-center text-[12px] font-bold text-[#E54855]"
>
{quitError.message ||
"미션을 포기하지 못했어요. 다시 시도해주세요."}
</p>
) : null}

<Button
variant="red"
size="md"
onClick={() => void handleQuit()}
isLoading={isQuitting}
className="mt-[20px] !h-[48px] !rounded-[15px] !bg-[#F05B67] !text-[14px]"
>
{isQuitting ? "포기 처리 중" : "미션 포기하기"}
</Button>
<Button
variant="secondary"
size="md"
onClick={closeQuitModal}
disabled={isQuitting}
className="mt-[8px] !h-[42px] !rounded-[14px] !bg-[#F4F8FF] !text-[13px] !text-[#5D6A7D]"
>
계속 진행하기
</Button>
</section>
</div>
) : null}
</QuespotPageContent>
);
}
Loading
Loading