- {data?.length === 0 && (
-
최근 보상 내역이 없습니다.
+
+ {isLoading ? (
+ Array.from({ length: 5 }, (_, index) => (
+
+ ))
+ ) : (
+ <>
+ {isError && !isFetchNextPageError && (
+
+ )}
+ {!isError && data?.length === 0 && (
+
최근 보상 내역이 없습니다.
+ )}
+ {data?.map((item) => (
+
+ ))}
+ {isFetchingNextPage && Array.from({ length: 5 }, (_, index) => (
+
+ ))}
+ {isFetchNextPageError && (
+
+ )}
+ {hasNextPage && !isError && (
+
+ )}
+ >
)}
- {data?.map((item) => (
-
- ))}
>
);
}
-
-type PointSummaryCardProps = {
- currentPoint: number;
- earnedPoint: number;
- usedPoint: number;
- isLoading: boolean;
- isError: boolean;
-};
-
-export function PointSummaryCard({
- currentPoint,
- earnedPoint,
- usedPoint,
- isLoading,
- isError,
-}: PointSummaryCardProps) {
- return (
-
-
- 보유 포인트
-
- {isLoading && (
-
- 포인트 불러오는중...
-
- )}
- {isError && (
-
- 오류가 발생했습니다. 다시 시도해 주세요.
-
- )}
- {!isError && !isLoading && (
- <>
-
-
- {currentPoint.toLocaleString()}
-
-
-
- P
-
-
-
-
-
- 획득 {earnedPoint.toLocaleString()}P
-
-
-
- 사용 {usedPoint.toLocaleString()}P
-
-
- >
- )}
-
- );
-}
diff --git a/src/pages/map/MapPage.tsx b/src/pages/map/MapPage.tsx
index b2ad046..8d661c3 100644
--- a/src/pages/map/MapPage.tsx
+++ b/src/pages/map/MapPage.tsx
@@ -1,3 +1,4 @@
+import { useInfiniteScroll } from "@/hooks/useInfiniteScroll";
import {
useEffect,
useMemo,
@@ -7,15 +8,7 @@ import {
type WheelEvent as ReactWheelEvent,
} from "react";
import { useLocation, useNavigate } from "react-router-dom";
-import {
- MapPin,
- Minus,
- Navigation,
- Plus,
- Search,
- X,
- ChevronRight,
-} from "lucide-react";
+import { MapPin, Minus, Navigation, Plus, X, LoaderCircle } from "lucide-react";
import {
APIProvider,
AdvancedMarker,
@@ -35,12 +28,16 @@ import {
import { PATH } from "@/routes/paths";
import { Header } from "@/components/common/Header";
import { useMissionSpotsQuery } from "@/hooks/queries/missionSpots/useMissionSpotsQuery";
-import { DistrictMissions, MissionSpot } from "@/apis/missionSpot";
+import { DistrictMission, MissionSpot } from "@/apis/missionSpot";
import { useNearbyMissionSpotsQuery } from "@/hooks/queries/missionSpots/useNearbyMissionSpotsQuery";
import { useDistrictMissionsQuery } from "@/hooks/queries/missionSpots/useDistrictMissionsQuery";
-import Button from "@/components/common/Button";
+import MissionListCard from "@/components/map/MissionListCard/MissionListCard";
+import SpotSummaryCard from "@/components/map/SpotSummaryCard/SpotSummaryCard";
+import MapMissionSummarySkeleton from "@/components/map/MapMissionSummarySkeleton";
+import SpotSummaryCardSkeleton from "@/components/map/SpotSummaryCard/SpotSummaryCardSkeleton";
+import MissionListCardSkeleton from "@/components/map/MissionListCard/MissionListCardSkeleton";
-type LocationStatus = "loading" | "success" | "error";
+type LocationStatus = "loading" | "success" | "denied" | "error";
type MapPageState = {
selectedSpotId?: string;
@@ -52,7 +49,6 @@ export default function MapPage() {
const location = useLocation();
const [selectedSpotId, setSelectedSpotId] = useState
("");
- const [isPlaceListOpen, setIsPlaceListOpen] = useState(false);
const [isMissionSheetOpen, setIsMissionSheetOpen] = useState(false);
const spotScrollDragRef = useRef<{
pointerId: number;
@@ -70,8 +66,16 @@ export default function MapPage() {
data: listData,
isLoading: isListLoading,
isError: isListError,
+ hasNextPage,
+ fetchNextPage,
+ isFetching: isListFetching,
+ isFetchingNextPage,
+ isFetchNextPageError,
} = useDistrictMissionsQuery(selectedSpotId);
- console.log(listData?.missions);
+ const missions = useMemo(() => {
+ const items = listData?.pages.flatMap((page) => page.missions) ?? [];
+ return Array.from(new globalThis.Map(items.map((item) => [item.missionId, item])).values());
+ }, [listData]);
const {
data: nearData,
isLoading: isNearLoading,
@@ -84,6 +88,7 @@ export default function MapPage() {
}
: null,
);
+ const isNearbyLoading = locationStatus === "loading" || isNearLoading;
useEffect(() => {
const state = location.state as MapPageState | null;
@@ -96,7 +101,7 @@ export default function MapPage() {
if (!hasSelectedSpot) return;
setSelectedSpotId(state.selectedSpotId);
- setIsPlaceListOpen(Boolean(state.openPlaceList));
+ setIsMissionSheetOpen(Boolean(state.openPlaceList));
window.history.replaceState({}, document.title);
}, [location.state, data]);
@@ -108,10 +113,13 @@ export default function MapPage() {
}, [data, selectedSpotId]);
const selectedSpot = useMemo(() => {
- return data?.missionSpots.find(
- (spot) => spot.districtCode === selectedSpotId,
+ return (
+ data?.missionSpots.find((spot) => spot.districtCode === selectedSpotId) ??
+ nearData?.missionSpots.find(
+ (spot) => spot.districtCode === selectedSpotId,
+ )
);
- }, [selectedSpotId, data]);
+ }, [selectedSpotId, data, nearData]);
const handleSelectSpot = (spotId: string) => {
setSelectedSpotId(spotId);
@@ -119,7 +127,6 @@ export default function MapPage() {
const handleSelectSpotCard = (spotId: string) => {
setSelectedSpotId(spotId);
- setIsPlaceListOpen(true);
};
const handleMoveRoutePage = (place: RoutePlace) => {
@@ -134,13 +141,14 @@ export default function MapPage() {
const handleSpotScrollPointerDown = (
event: ReactPointerEvent,
) => {
+ if (!event.isPrimary || event.button !== 0) return;
+ suppressSpotClickRef.current = false;
spotScrollDragRef.current = {
pointerId: event.pointerId,
startX: event.clientX,
scrollLeft: event.currentTarget.scrollLeft,
moved: false,
};
- event.currentTarget.setPointerCapture(event.pointerId);
};
const handleSpotScrollPointerMove = (
@@ -150,7 +158,11 @@ export default function MapPage() {
if (!drag || drag.pointerId !== event.pointerId) return;
const distance = event.clientX - drag.startX;
- if (Math.abs(distance) > 4) drag.moved = true;
+ if (!drag.moved) {
+ if (Math.abs(distance) <= 4) return;
+ drag.moved = true;
+ event.currentTarget.setPointerCapture(event.pointerId);
+ }
event.currentTarget.scrollLeft = drag.scrollLeft - distance;
};
@@ -165,9 +177,6 @@ export default function MapPage() {
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
event.currentTarget.releasePointerCapture(event.pointerId);
}
- window.setTimeout(() => {
- suppressSpotClickRef.current = false;
- }, 0);
};
console.log(data);
@@ -243,9 +252,19 @@ export default function MapPage() {
)}
- {isLoading && (
-
- 로딩중...
+ {isLoading && apiKey && (
+
)}
{isError && (
@@ -255,21 +274,32 @@ export default function MapPage() {
)}
-
-
- {data?.regionName} 미션
-
-
-
-
- {data?.totalMissionCount}개 스팟
-
- ·
-
- {data?.completedMissionCount}완료
-
-
-
+ {(isLoading || data) && (
+
+ {isLoading ? (
+
+ ) : (
+ <>
+
+ {data?.regionName} 미션
+
+
+
+
+ {data?.totalSpotCount}개 스팟
+
+ ·
+
+ {data?.completedMissionCount}완료
+
+
+ >
+ )}
+
+ )}
@@ -279,19 +309,18 @@ export default function MapPage() {
주변 미션 스팟
-
-
{
+ if (event.detail > 0 && suppressSpotClickRef.current) {
+ event.preventDefault();
+ event.stopPropagation();
+ suppressSpotClickRef.current = false;
+ }
+ }}
onPointerCancel={handleSpotScrollPointerEnd}
onPointerDown={handleSpotScrollPointerDown}
onPointerLeave={handleSpotScrollPointerEnd}
@@ -301,36 +330,51 @@ export default function MapPage() {
event.currentTarget.scrollLeft += event.deltaX || event.deltaY;
}}
>
- {isNearLoading && (
-
- 데이터를 불러오는 중 입니다...
-
+ {isNearbyLoading &&
+ Array.from({ length: 6 }, (_, index) => (
+
+ ))}
+ {(locationStatus === "denied" || locationStatus === "error") && (
+
+ {locationStatus === "denied"
+ ? "주변 미션을 보려면 브라우저 설정에서 위치 권한을 허용한 뒤 페이지를 새로고침해 주세요."
+ : "현재 위치를 확인하지 못했어요. 기기의 위치 설정을 확인한 뒤 페이지를 새로고침해 주세요."}
+
)}
{isNearError && (
에러가 발생했습니다. 다시 시도해주세요.
)}
- {nearData?.missionSpots.map((spot) => (
-
{
- if (suppressSpotClickRef.current) return;
- handleSelectSpotCard(spot.districtCode);
- }}
- />
- ))}
+ {!isNearbyLoading &&
+ nearData?.missionSpots.map((spot) => (
+ {
+ handleSelectSpotCard(spot.districtCode);
+ }}
+ />
+ ))}
{isMissionSheetOpen && selectedSpot && (
<>
setIsMissionSheetOpen(false)}
/>
>
@@ -361,8 +405,8 @@ function useCurrentLocation() {
});
setLocationStatus("success");
},
- () => {
- setLocationStatus("error");
+ (error) => {
+ setLocationStatus(error.code === 1 ? "denied" : "error");
},
{
enableHighAccuracy: true,
@@ -597,66 +641,6 @@ function LegendItem({ color, label }: LegendItemProps) {
);
}
-type SpotSummaryCardProps = {
- spot: MissionSpot;
- selected: boolean;
- onClick: () => void;
-};
-
-//하단 주변 미션 스팟 카드
-function SpotSummaryCard({ spot, selected, onClick }: SpotSummaryCardProps) {
- const isCompleted = spot.completionStatus === "COMPLETE";
-
- return (
-
- );
-}
-
function MapApiKeyFallback() {
return (
@@ -677,27 +661,37 @@ function MapApiKeyFallback() {
type MissionListBottomSheetProps = {
spot: MissionSpot;
- listData: DistrictMissions | undefined;
+ missions: DistrictMission[];
+ hasNextPage: boolean;
+ fetchNextPage: () => Promise
;
+ isFetching: boolean;
+ isFetchingNextPage: boolean;
+ isFetchNextPageError: boolean;
isLoading: boolean;
isError: boolean;
onClose: () => void;
};
-export const missionStatusLabel = {
- AVAILABLE: "시작가능",
- IN_PROGRESS: "진행중",
- COMPLETED: "완료",
- LOCKED: "잠김",
-} as const;
-
function MissionListBottomSheet({
spot,
- listData,
+ missions,
+ hasNextPage,
+ fetchNextPage,
+ isFetching,
+ isFetchingNextPage,
+ isFetchNextPageError,
isLoading,
isError,
onClose,
}: MissionListBottomSheetProps) {
- const navigate = useNavigate();
+ const listRef = useRef(null);
+ const loadMoreRef = useInfiniteScroll({
+ hasNextPage,
+ isFetching,
+ isError,
+ fetchNextPage,
+ rootRef: listRef,
+ });
return (
event.stopPropagation()}
- className="relative flex flex-col h-[70%] w-full rounded-t-[28px] bg-white px-[20px] pb-[24px] pt-[12px] shadow-[0_-8px_30px_rgba(8,37,95,0.16)] min-h-0 overflow-hidden"
+ className="relative flex flex-col h-[70%] w-full rounded-t-[28px] bg-white pt-[12px] shadow-[0_-8px_30px_rgba(8,37,95,0.16)] min-h-0 overflow-hidden"
>
-
+
{spot.districtName} 미션
@@ -731,54 +725,36 @@ function MissionListBottomSheet({
{/* 미션 목록 */}
-
- {isLoading && (
-
- 데이터를 불러오는 중 입니다...
-
- )}
- {isError && (
+
+ {isLoading &&
+ Array.from({ length: 3 }, (_, index) => (
+
+ ))}
+ {isError && !isFetchNextPageError && (
에러가 발생했습니다. 다시 시도해주십시오.
)}
- {listData?.missions.map((data) => (
-
-
-

-
-
{data.title}
-
-
- {missionStatusLabel[data.userMissionStatus]} |{" "}
- {data.estimatedMinutes}분 | +{data.rewardPoint}
-
-
-
-
-
- ))}
+ {!isLoading &&
+ missions.map((data) => (
+
+ ))}
+ {isFetchingNextPage &&
+ Array.from({ length: 3 }, (_, index) => (
+
+ ))}
+ {isFetchNextPageError && (
+
+ 다음 미션을 불러오지 못했어요. 잠시 후 다시 확인해 주세요.
+
+ )}
+ {hasNextPage && !isError && (
+
+ )}