diff --git a/public/img_pravel.png b/public/img_pravel.png index 26f8d2e5..56433c77 100644 Binary files a/public/img_pravel.png and b/public/img_pravel.png differ diff --git a/public/quest/quest.png b/public/quest/quest.png new file mode 100644 index 00000000..97fff43b Binary files /dev/null and b/public/quest/quest.png differ diff --git a/public/record/travel/default.png b/public/record/travel/default.png new file mode 100644 index 00000000..53ec638d Binary files /dev/null and b/public/record/travel/default.png differ diff --git a/src/app/page.tsx b/src/app/page.tsx index c0eba2fa..bc797b56 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,9 +1,10 @@ 'use client'; -import { useEffect } from 'react'; +import { useEffect, useState } from 'react'; import WishList from '@/components/main/AddOption/WishList'; import ScheduleList from '@/components/main/Schedule/ScheduleList'; +import QuestModal from '@/components/quest/modal/QuestModal'; import useModal, { MODAL } from '@/hook/useModal'; import { useFetchPlan } from '@/hook/usePlan'; import Header from '@/layout/header/Header'; @@ -16,9 +17,11 @@ import FloatingBar from '../components/main/FloatingBar'; import Gnb from '../layout/navigation/Gnb'; const Home = () => { + const [questId, setQuestId] = useState(null); const [modalState, { openModal, closeModal }] = useModal({ [MODAL.ADD_OPTION]: false, [MODAL.WISH_LIST]: false, + [MODAL.QUEST]: false, }); const { data, isError, isFetching } = useFetchPlan(); @@ -52,7 +55,13 @@ const Home = () => { - openModal(MODAL.ADD_OPTION)} /> + openModal(MODAL.ADD_OPTION)} + openQuest={(id) => { + setQuestId(id); + openModal(MODAL.QUEST); + }} + /> {modalState.addOption && ( { }} /> )} + {modalState.quest && questId && ( + closeModal(MODAL.QUEST)} /> + )} ); }; diff --git a/src/app/travel-record/page.tsx b/src/app/travel-record/page.tsx index 6d2ba747..64f1a9f0 100644 --- a/src/app/travel-record/page.tsx +++ b/src/app/travel-record/page.tsx @@ -1,3 +1,5 @@ +import Image from 'next/image'; + import SwiperCarousel from '@/components/common/carousel/SwiperCarousel'; import TravelList from '@/components/record/TravelList'; import Gnb from '@/layout/navigation/Gnb'; @@ -33,7 +35,18 @@ const TravelRecord = async () => { {list.length ? ( ) : ( -
+
+ default +

+ 여행 기록이 없어요 :( +

+
)}
diff --git a/src/components/main/FloatingBar.tsx b/src/components/main/FloatingBar.tsx index a956c7d9..6ceba3e3 100644 --- a/src/components/main/FloatingBar.tsx +++ b/src/components/main/FloatingBar.tsx @@ -1,10 +1,14 @@ +import QuestButton from './QuestButton'; + interface FloatingBarProps { openAddOption: () => void; + openQuest: (questId: number) => void; } -const FloatingBar = ({ openAddOption }: FloatingBarProps) => { +const FloatingBar = ({ openAddOption, openQuest }: FloatingBarProps) => { return (
    +
  • + {enabled && ( +
      + {data?.map((quest, i) => ( +
    • openQuest(quest.id)} + > + {`Q${i + 1}`} +
    • + ))} + +
    • + +
    • +
    + )} +
  • + ); +}; + +export default QuestButton; diff --git a/src/components/quest/modal/QuestModal.tsx b/src/components/quest/modal/QuestModal.tsx new file mode 100644 index 00000000..078c6230 --- /dev/null +++ b/src/components/quest/modal/QuestModal.tsx @@ -0,0 +1,86 @@ +'use client'; + +import { Bounce, toast } from 'react-toastify'; +import Image from 'next/image'; + +import ModalBackdrop from '@/components/mypage/modal/ModalBackdrop'; +import { useFetchQuest, useStartQuestMutation } from '@/hook/useQuest'; +import { usePlanStateStore } from '@/store'; + +interface QuestModalProps { + id: number; + close: () => void; +} + +const QuestModal = ({ id }: QuestModalProps) => { + const { data: quest, isLoading, isError } = useFetchQuest(id); + const { currentDate, planId } = usePlanStateStore(); + const startQuestMutation = useStartQuestMutation(); + + if (isLoading) return null; + if (isError) { + close(); + return null; + } + + const handleStartQuest = () => { + startQuestMutation.mutate({ + id, + planId, + date: currentDate, + }); + }; + + return ( +
    + + +
    +

    + Quest +

    +

    + + [{quest!.title}]하고 기념품을 + 받아보세요! +
    일정에 참여해 보실래요? +
    +

    + +
    +
    + 퀘스트 보상 + + + 에너지 {quest!.energy}% + +
    +
    + +
    + 퀘스트 이미지 +
    + +
    + + +
    +
    +
    + ); +}; + +export default QuestModal; diff --git a/src/hook/useModal.ts b/src/hook/useModal.ts index 3fead544..0733ddbd 100644 --- a/src/hook/useModal.ts +++ b/src/hook/useModal.ts @@ -16,6 +16,7 @@ export enum MODAL { ONBOARDING_CALENDAR = 'onboardingCalendar', ONBOARDING_SEARCH_LOCATION = 'onboardingSearchLocation', SHARE_LINK = 'shareLink', + QUEST = 'quest', } const useModal = (initState: ModalState = {}): [ModalState, ModalActions] => { diff --git a/src/hook/useQuest.ts b/src/hook/useQuest.ts new file mode 100644 index 00000000..a65132ec --- /dev/null +++ b/src/hook/useQuest.ts @@ -0,0 +1,46 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; + +import getLocation from '@/services/api/location.api'; +import * as questApi from '@/services/api/quest.api'; +import { LocationData } from '@/types/search.type'; +import { boundToast } from '@/utils/toastUtils'; + +export const useFetchQuestList = (enabled: boolean) => { + const queryClient = useQueryClient(); + + return useQuery({ + queryKey: ['quest'], + queryFn: async () => { + let location = queryClient.getQueryData(['location']) as LocationData; + + if (!location) { + location = await getLocation(); + } + + return questApi.getQuestList(location); + }, + staleTime: 5 * 3_600 * 1000, + enabled, + }); +}; + +export const useFetchQuest = (questId: number) => + useQuery({ + queryKey: ['quest', questId], + queryFn: async () => { + return questApi.getQuest(questId); + }, + }); + +export const useStartQuestMutation = () => + useMutation({ + mutationFn: questApi.startQuest, + + onSuccess() { + boundToast('추가되었습니다'); + }, + + onError() { + boundToast('퀘스트를 시작할 수 없습니다.', 'warning'); + }, + }); diff --git a/src/services/api/quest.api.ts b/src/services/api/quest.api.ts new file mode 100644 index 00000000..384b5697 --- /dev/null +++ b/src/services/api/quest.api.ts @@ -0,0 +1,45 @@ +import { Quest, StartQuestRequest } from '@/types/quest.type'; +import { LocationData } from '@/types/search.type'; + +import { baseURL, setDefaultHeader } from '.'; + +const QUEST = '/quest'; + +export const getQuestList = async ({ + lat, + lng, +}: LocationData): Promise => + fetch(`${baseURL}${QUEST}?x=${lat}&y=${lng}`, { + headers: await setDefaultHeader(), + }).then((res) => { + if (!res.ok) { + throw new Error('Network response was not ok'); + } + + return res.json(); + }); + +export const getQuest = async (questId: number): Promise => + fetch(`${baseURL}${QUEST}/${questId}`, { + headers: await setDefaultHeader(), + }).then((res) => { + if (!res.ok) { + throw new Error('Network response was not ok'); + } + + return res.json(); + }); + +export const startQuest = async ({ + id, + ...props +}: StartQuestRequest): Promise => + fetch(`${baseURL}${QUEST}/${id}`, { + method: 'POST', + headers: await setDefaultHeader(), + body: JSON.stringify(props), + }).then((res) => { + if (!res.ok) { + throw new Error('Network response was not ok'); + } + }); diff --git a/src/styles/icons.css b/src/styles/icons.css index 96f9043d..89d0114d 100644 --- a/src/styles/icons.css +++ b/src/styles/icons.css @@ -296,3 +296,8 @@ height: 56px; background-position: 0 -240px; } +.ico_energy_fill24 { + width: 24px; + height: 24px; + background-position: -240px -40px; +} diff --git a/src/types/quest.type.ts b/src/types/quest.type.ts new file mode 100644 index 00000000..76c8273d --- /dev/null +++ b/src/types/quest.type.ts @@ -0,0 +1,11 @@ +export interface Quest { + id: number; + energy: number; + title: string; +} + +export interface StartQuestRequest { + id: number; + planId: number; + date: string; +} diff --git a/src/utils/toastUtils.ts b/src/utils/toastUtils.ts new file mode 100644 index 00000000..9b6702c1 --- /dev/null +++ b/src/utils/toastUtils.ts @@ -0,0 +1,16 @@ +import { Bounce, toast, TypeOptions } from 'react-toastify'; + +export const boundToast = (message: string, type: TypeOptions = 'success') => { + toast(message, { + position: 'top-center', + autoClose: 3000, + hideProgressBar: false, + closeOnClick: true, + pauseOnHover: true, + draggable: true, + progress: undefined, + theme: 'colored', + transition: Bounce, + type, + }); +};