From 26796f91ebc99c8ccffec5ca3da701f099d388b2 Mon Sep 17 00:00:00 2001 From: Hyejin Yang Date: Wed, 29 Nov 2023 19:10:37 +0900 Subject: [PATCH 01/11] =?UTF-8?q?feat(volunteer):=20=EB=B4=89=EC=82=AC=20?= =?UTF-8?q?=ED=9B=84=EA=B8=B0=20=EC=88=98=EC=A0=95=20=ED=8E=98=EC=9D=B4?= =?UTF-8?q?=EC=A7=80=20=EC=A7=84=EC=9E=85=20=EC=8B=9C,=20=EA=B8=B0?= =?UTF-8?q?=EC=A1=B4=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20form=20=EC=97=90=20?= =?UTF-8?q?=EB=84=A3=EC=96=B4=EC=A3=BC=EB=8A=94=20=EA=B8=B0=EB=8A=A5=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20=EB=B0=8F=20msw=20=EC=97=B0=EA=B2=B0=20(#2?= =?UTF-8?q?29)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(volunteer): getVolunteerReviewDetail 함수의 api 요청 url 수정 * fix(volunteer): reviewDetailResponse type 수정 * feat(volunteer): 봉사 리뷰 상세조회 mock API 추가 * feat(volunteer): useUploadPhoto hook 에 setImageUrls 함수 추가 * feat(volunteer): 봉사 후기 수정 페이지 진입 시, 기존 후기 데이터를input 에 set 해주는 기능 추가 * fix(volunteer): 사용하지 않는 변수 제거 --- apps/volunteer/src/apis/review.ts | 2 +- apps/volunteer/src/mocks/handlers/review.ts | 17 ++++++++- .../pages/shelters/reviews/update/index.tsx | 38 +++++++++++++++++-- apps/volunteer/src/types/apis/review.ts | 4 +- packages/shared/hooks/useUploadPhoto.ts | 11 ++++++ 5 files changed, 64 insertions(+), 8 deletions(-) diff --git a/apps/volunteer/src/apis/review.ts b/apps/volunteer/src/apis/review.ts index e69c0930..3c7a446d 100644 --- a/apps/volunteer/src/apis/review.ts +++ b/apps/volunteer/src/apis/review.ts @@ -9,7 +9,7 @@ import { } from '@/types/apis/review'; export const getVolunteerReviewDetail = (reviewId: number) => - axiosInstance.get(`/reviews/${reviewId}`); + axiosInstance.get(`/volunteers/reviews/${reviewId}`); export const createVolunteerReview = (reqeust: ReviewCreateRequest) => axiosInstance.post( diff --git a/apps/volunteer/src/mocks/handlers/review.ts b/apps/volunteer/src/mocks/handlers/review.ts index 9727882b..9f9df04a 100644 --- a/apps/volunteer/src/mocks/handlers/review.ts +++ b/apps/volunteer/src/mocks/handlers/review.ts @@ -5,8 +5,23 @@ export const handlers = [ await delay(200); return HttpResponse.json(null, { status: 200 }); }), - http.put('/volunteers/reviews/:id', async () => { + http.patch('/volunteers/reviews/:id', async () => { await delay(200); return HttpResponse.json(null, { status: 200 }); }), + http.get('/volunteers/reviews/:id', async () => { + await delay(200); + return HttpResponse.json( + { + reviewId: 1, + reviewContent: `정말 강아지들이 귀여워서 봉사할 맛이 났어요!\n유기견 입양 한다면 꼭 여기서 입양할 거에요!`, + reviewImageUrls: [ + 'https://source.unsplash.com/random/1', + 'https://source.unsplash.com/random/2', + 'https://source.unsplash.com/random/3', + ], + }, + { status: 200 }, + ); + }), ]; diff --git a/apps/volunteer/src/pages/shelters/reviews/update/index.tsx b/apps/volunteer/src/pages/shelters/reviews/update/index.tsx index 2b7ecdca..dc4e881b 100644 --- a/apps/volunteer/src/pages/shelters/reviews/update/index.tsx +++ b/apps/volunteer/src/pages/shelters/reviews/update/index.tsx @@ -10,13 +10,14 @@ import { } from '@chakra-ui/react'; import { zodResolver } from '@hookform/resolvers/zod'; import { useMutation, useQuery } from '@tanstack/react-query'; +import { useCallback, useEffect } from 'react'; import { SubmitHandler, useForm } from 'react-hook-form'; import { useNavigate, useParams } from 'react-router-dom'; import EditPhotoList from 'shared/components/EditPhotoList'; import ProfileInfo from 'shared/components/ProfileInfo'; import { useUploadPhoto } from 'shared/hooks/useUploadPhoto'; -import { updateVolunteerReview } from '@/apis/review'; +import { getVolunteerReviewDetail, updateVolunteerReview } from '@/apis/review'; import { getSimpleShelterProfile } from '@/apis/shelter'; import PATH from '@/constants/path'; import ReviewSubmitButton from '@/pages/shelters/reviews/_components/ReviewSubmitButton'; @@ -40,7 +41,7 @@ export default function SheltersReviewsUpdatePage() { navigate(-1); } - const { data } = useQuery({ + const { data: shelterInfo } = useQuery({ queryKey: ['shelterProfile', Number(shelterId)], queryFn: async () => { return (await getSimpleShelterProfile(Number(shelterId))).data; @@ -53,6 +54,18 @@ export default function SheltersReviewsUpdatePage() { }, }); + const { data: reviewDetail } = useQuery({ + queryKey: ['review', 'detail', Number(reviewId)], + queryFn: async () => { + return (await getVolunteerReviewDetail(Number(reviewId))).data; + }, + initialData: { + reviewId: 0, + reviewContent: '', + reviewImageUrls: [], + }, + }); + const { mutate, isPending } = useMutation({ mutationFn: ({ reviewId, @@ -66,22 +79,39 @@ export default function SheltersReviewsUpdatePage() { }, }); - const { shelterName, shelterImageUrl, shelterAddress, shelterEmail } = data; + const { shelterName, shelterImageUrl, shelterAddress, shelterEmail } = + shelterInfo; const { register, handleSubmit, watch, + setValue, + setFocus, formState: { errors }, } = useForm({ resolver: zodResolver(reviewSchema), }); - const { photos, handleUploadPhoto, handleDeletePhoto } = + const { photos, setImageUrls, handleUploadPhoto, handleDeletePhoto } = useUploadPhoto(UPLOAD_LIMIT); const contentLength = watch('content')?.length ?? 0; + const setReviewFormvalues = useCallback( + (content: string, imageUrls: string[]) => { + setValue('content', content); + setImageUrls(imageUrls); + }, + [], + ); + + useEffect(() => { + const { reviewContent, reviewImageUrls } = reviewDetail; + setReviewFormvalues(reviewContent, reviewImageUrls); + setFocus('content'); + }, [reviewDetail, setReviewFormvalues, setFocus]); + const onSubmit: SubmitHandler = (data: ReviewSchema) => { const { content } = data; diff --git a/apps/volunteer/src/types/apis/review.ts b/apps/volunteer/src/types/apis/review.ts index 222c7533..0ba3264a 100644 --- a/apps/volunteer/src/types/apis/review.ts +++ b/apps/volunteer/src/types/apis/review.ts @@ -10,8 +10,8 @@ export type Pagination = { export type ReviewDetailResponse = { reviewId: number; - content: string; - imageUrls: string[]; + reviewContent: string; + reviewImageUrls: string[]; }; export type ReviewCreateRequest = { diff --git a/packages/shared/hooks/useUploadPhoto.ts b/packages/shared/hooks/useUploadPhoto.ts index 9546a51f..4f68817b 100644 --- a/packages/shared/hooks/useUploadPhoto.ts +++ b/packages/shared/hooks/useUploadPhoto.ts @@ -66,6 +66,16 @@ export const useUploadPhoto = (uploadLimit: number) => { const toast = useToast(); + const setImageUrls = (imageUrls: string[]) => { + const newPhotos: Photo[] = + imageUrls.map((url) => ({ + id: getRandomId(), + url, + })) || []; + + setPhotos(newPhotos); + }; + const handleUploadPhoto = (files: FileList | null) => { if (!files) { return; @@ -110,6 +120,7 @@ export const useUploadPhoto = (uploadLimit: number) => { return { photos, + setImageUrls, handleUploadPhoto, handleDeletePhoto, }; From 34ecce65f9d95178af506094a0d6b22cf8d0a232 Mon Sep 17 00:00:00 2001 From: Hyejin Yang Date: Thu, 30 Nov 2023 14:11:42 +0900 Subject: [PATCH 02/11] =?UTF-8?q?fix(shared):=20global=20theme=20=EC=9D=98?= =?UTF-8?q?=20overscrollbehavior=20css=20=EC=86=8D=EC=84=B1=EC=9D=84=20emo?= =?UTF-8?q?tion=20=EC=9D=84=20=EC=B9=B4=EB=A9=9C=20=EC=BC=80=EC=9D=B4?= =?UTF-8?q?=EC=8A=A4=EB=A1=9C=20=EC=88=98=EC=A0=95=20(#235)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/shared/theme/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/shared/theme/index.ts b/packages/shared/theme/index.ts index 2e975dbf..1b305036 100644 --- a/packages/shared/theme/index.ts +++ b/packages/shared/theme/index.ts @@ -8,7 +8,7 @@ const theme = extendTheme({ styles: { global: { body: { - 'overscroll-behavior': 'none', + overscrollBehavior: 'none', }, }, }, From a0031ab5080afa8baa52bbfe68ac62d28d4f57e0 Mon Sep 17 00:00:00 2001 From: Hyejin Yang Date: Thu, 30 Nov 2023 15:22:02 +0900 Subject: [PATCH 03/11] =?UTF-8?q?feat(shelter):=20=EB=B4=89=EC=82=AC?= =?UTF-8?q?=EC=9E=90=20=EB=AA=A8=EC=A7=91=EA=B8=80=20=EB=A6=AC=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=EC=97=90=EC=84=9C=20=EC=95=84=EC=9D=B4=ED=85=9C=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD=EC=8B=9C,=20=EC=BA=90=EC=8B=B1=EB=90=9C=20?= =?UTF-8?q?=EC=84=9C=EB=B2=84=20=EC=83=81=ED=85=8C=20=EC=97=85=EB=8D=B0?= =?UTF-8?q?=EC=9D=B4=ED=8A=B8=ED=95=98=EB=8A=94=20=EA=B8=B0=EB=8A=A5=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20(#232)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(shelter): 봉사자 모집 아이템 수정 또는 삭제 시, 캐싱된 서버 데이터 업데이트하는 로직 추가 * feat(shelter): 봉사 모집글 리스트 Mock API 의 response 로 항상 다른 응답이 가는 기능 추가 --- .../shelter/src/mocks/handlers/recruitment.ts | 39 +++++++------- .../_hooks/useVolunteerRecruitItem.ts | 54 ++++++++++++++++++- 2 files changed, 73 insertions(+), 20 deletions(-) diff --git a/apps/shelter/src/mocks/handlers/recruitment.ts b/apps/shelter/src/mocks/handlers/recruitment.ts index 057c76a4..79ca0e06 100644 --- a/apps/shelter/src/mocks/handlers/recruitment.ts +++ b/apps/shelter/src/mocks/handlers/recruitment.ts @@ -1,22 +1,25 @@ import { delay, http, HttpResponse } from 'msw'; -const DUMMY_RECRUITMENT = { - recruitmentId: 1, - recruitmentTitle: '봉사자를 모집합니다', - recruitmentStartTime: '2021-11-08T11:44:30.327959', - recruitmentEndTime: '2021-11-08T11:44:30.327959', - recruitmentDeadline: '2023-11-20T11:44:30.327959', - recruitmentIsClosed: false, - recruitmentApplicantCount: 15, - recruitmentCapacity: 15, -}; +const randomDate = (start: Date, end: Date) => + new Date(start.getTime() + Math.random() * (end.getTime() - start.getTime())); -// eslint-disable-next-line -// @ts-ignore -const DUMMY_RECRUITMENT_LIST = Array.from( - { length: 4 }, - () => DUMMY_RECRUITMENT, -); +const currentDate = new Date(); +const startDate = new Date(currentDate); +const endDate = new Date(currentDate); +startDate.setDate(currentDate.getDate() - 30); +endDate.setDate(endDate.getDate() + 30); + +const getRandomDummyRecruitment = () => ({ + recruitmentId: Number(String(Math.random()).slice(2)), + shelterId: Number(String(Math.random()).slice(2)), + recruitmentTitle: '봉사자를 모집합니다', + recruitmentStartTime: randomDate(startDate, endDate), + recruitmentEndTime: randomDate(startDate, endDate), + recruitmentDeadline: randomDate(startDate, endDate), + recruitmentIsClosed: Boolean(Math.floor(Math.random() * 2)), + recruitmentApplicantCount: Math.floor(Math.random() * 15), + recruitmentCapacity: 20, +}); export const DUMMY_APPLICANT = { applicantId: 10, @@ -44,9 +47,7 @@ export const handlers = [ hasNext: true, }, recruitments: Array.from({ length: 4 }, () => ({ - ...DUMMY_RECRUITMENT, - recruitmentId: Math.random(), - shelterId: Math.random(), + ...getRandomDummyRecruitment(), })), }, { status: 200 }, diff --git a/apps/shelter/src/pages/volunteers/_hooks/useVolunteerRecruitItem.ts b/apps/shelter/src/pages/volunteers/_hooks/useVolunteerRecruitItem.ts index 82335c35..5208e17e 100644 --- a/apps/shelter/src/pages/volunteers/_hooks/useVolunteerRecruitItem.ts +++ b/apps/shelter/src/pages/volunteers/_hooks/useVolunteerRecruitItem.ts @@ -1,5 +1,10 @@ import { useDisclosure } from '@chakra-ui/react'; -import { useMutation } from '@tanstack/react-query'; +import { + InfiniteData, + useMutation, + useQueryClient, +} from '@tanstack/react-query'; +import { AxiosResponse } from 'axios'; import { useState } from 'react'; import { useNavigate } from 'react-router-dom'; @@ -7,6 +12,7 @@ import { closeShelterRecruitment, deleteShelterRecruitment, } from '@/apis/recruitment'; +import { RecruitementsResponse, Recruitment } from '@/types/apis/recruitment'; export const useVolunteerRecruitItem = () => { const navigate = useNavigate(); @@ -19,11 +25,42 @@ export const useVolunteerRecruitItem = () => { onClick: () => {}, }); + const queryClient = useQueryClient(); + + const getNewPages = ( + data: InfiniteData>, + processRecruitments: (recruitments: Recruitment[]) => Recruitment[], + ) => + data.pages.map((page) => ({ + ...page, + data: { + pageInfo: page.data.pageInfo, + recruitments: processRecruitments(page.data.recruitments), + }, + })); + const closeRecruitment = useMutation({ mutationFn: async (recruitmentId: number) => { await closeShelterRecruitment(recruitmentId); onClose(); }, + onSuccess: (_, recruitmentId) => { + queryClient.setQueryData( + ['recruitments'], + (data: InfiniteData>) => { + return { + pages: getNewPages(data, (recruitments) => + recruitments.map((recruitment) => + recruitment.recruitmentId === recruitmentId + ? { ...recruitment, recruitmentIsClosed: true } + : recruitment, + ), + ), + pageParams: data.pageParams, + }; + }, + ); + }, }); const deleteRecruitment = useMutation({ @@ -31,6 +68,21 @@ export const useVolunteerRecruitItem = () => { await deleteShelterRecruitment(recruitmentId); onClose(); }, + onSuccess: (_, recruitmentId) => { + queryClient.setQueryData( + ['recruitments'], + (data: InfiniteData>) => { + return { + pages: getNewPages(data, (recruitments) => + recruitments.filter( + (recruitment) => recruitment.recruitmentId !== recruitmentId, + ), + ), + pageParams: data.pageParams, + }; + }, + ); + }, }); const goVolunteersDetail = (recruitmentId: number) => { From c227e695bfc1481482d2be0541c7e6f7785f87c6 Mon Sep 17 00:00:00 2001 From: Hyejin Yang Date: Thu, 30 Nov 2023 15:27:56 +0900 Subject: [PATCH 04/11] =?UTF-8?q?feat(shelter):=20=EB=B4=89=EC=82=AC?= =?UTF-8?q?=EC=9E=90=20=EB=AA=A8=EC=A7=91=EA=B8=80=20=EC=88=98=EC=A0=95=20?= =?UTF-8?q?=ED=8E=98=EC=9D=B4=EC=A7=80=20=EC=A7=84=EC=9E=85=20=EC=8B=9C,?= =?UTF-8?q?=20=EA=B8=B0=EC=A1=B4=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20=EB=A6=AC?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EB=B3=B4=EC=97=AC=EC=A3=BC=EB=8A=94=20?= =?UTF-8?q?=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80=20(#231)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(shelter): 이미지 업로드 mock API 추가 * feat(shelter): 봉사 모집글 작성을 위한 RecruitmentDetail 타입 정의 * feat(shelter): 봉사자 모집글 수정페이지에서 기존 이미지 리스트 보여주는 기능 추가 --- apps/shelter/src/mocks/browser.ts | 6 ++-- apps/shelter/src/mocks/handlers/image.ts | 14 +++++++++ .../detail/_hooks/useGetVolunteerDetail.ts | 18 ++++++++++- .../src/pages/volunteers/update/index.tsx | 30 ++++++++++++------- 4 files changed, 55 insertions(+), 13 deletions(-) create mode 100644 apps/shelter/src/mocks/handlers/image.ts diff --git a/apps/shelter/src/mocks/browser.ts b/apps/shelter/src/mocks/browser.ts index 3808427e..12e74902 100644 --- a/apps/shelter/src/mocks/browser.ts +++ b/apps/shelter/src/mocks/browser.ts @@ -1,6 +1,7 @@ import { setupWorker } from 'msw/browser'; import { handlers as authHandlers } from './handlers/auth'; +import { handlers as imageHandlers } from './handlers/image'; import { handlers as manageHandlers } from './handlers/manage'; import { handlers as recruitmentHandler } from './handlers/recruitment'; import { handlers as recruitmentDetailHandler } from './handlers/recruitmentDetail'; @@ -9,9 +10,10 @@ import { handlers as volunteerHandlers } from './handlers/volunteers'; export const worker = setupWorker( ...authHandlers, - ...shelterHandlers, + ...imageHandlers, + ...manageHandlers, ...recruitmentHandler, ...recruitmentDetailHandler, - ...manageHandlers, + ...shelterHandlers, ...volunteerHandlers, ); diff --git a/apps/shelter/src/mocks/handlers/image.ts b/apps/shelter/src/mocks/handlers/image.ts new file mode 100644 index 00000000..d1284b5c --- /dev/null +++ b/apps/shelter/src/mocks/handlers/image.ts @@ -0,0 +1,14 @@ +import { delay, http, HttpResponse } from 'msw'; + +export const handlers = [ + http.post('/images', async () => { + await delay(200); + + return HttpResponse.json( + { + imageUrls: ['https://source.unsplash.com/random'], + }, + { status: 200 }, + ); + }), +]; diff --git a/apps/shelter/src/pages/volunteers/detail/_hooks/useGetVolunteerDetail.ts b/apps/shelter/src/pages/volunteers/detail/_hooks/useGetVolunteerDetail.ts index 95172ad3..3edd91f3 100644 --- a/apps/shelter/src/pages/volunteers/detail/_hooks/useGetVolunteerDetail.ts +++ b/apps/shelter/src/pages/volunteers/detail/_hooks/useGetVolunteerDetail.ts @@ -4,7 +4,23 @@ import { RecruitmentDetailResponse, } from 'shared/apis/common/Recruitments'; -const createRecruitmentDetail = (recruitment: RecruitmentDetailResponse) => { +export type RecruitmentDetail = { + title: string; + content: string; + applicant: number; + capacity: number; + startTime: string; + endTime: string; + deadline: string; + createdAt: string; + updatedAt: string; + imageUrls: string[]; + isClosed: boolean; +}; + +const createRecruitmentDetail = ( + recruitment: RecruitmentDetailResponse, +): RecruitmentDetail => { const { recruitmentTitle: title, recruitmentContent: content, diff --git a/apps/shelter/src/pages/volunteers/update/index.tsx b/apps/shelter/src/pages/volunteers/update/index.tsx index af0ee44b..c1365c9f 100644 --- a/apps/shelter/src/pages/volunteers/update/index.tsx +++ b/apps/shelter/src/pages/volunteers/update/index.tsx @@ -13,7 +13,7 @@ import { } from '@chakra-ui/react'; import { zodResolver } from '@hookform/resolvers/zod'; import { useMutation, useQueryClient } from '@tanstack/react-query'; -import { useEffect } from 'react'; +import { useCallback, useEffect } from 'react'; import { SubmitHandler, useForm } from 'react-hook-form'; import { useNavigate, useParams } from 'react-router-dom'; import EditPhotoList from 'shared/components/EditPhotoList'; @@ -23,7 +23,9 @@ import * as z from 'zod'; import { updateShelterRecruitment } from '@/apis/recruitment'; import type { RecruitmentUpdateRequest } from '@/types/apis/recruitment'; -import useGetVolunteerDetail from '../detail/_hooks/useGetVolunteerDetail'; +import useGetVolunteerDetail, { + RecruitmentDetail, +} from '../detail/_hooks/useGetVolunteerDetail'; const recruitmentSchema = z .object({ @@ -74,7 +76,7 @@ export default function VolunteersUpdatePage() { } = useForm({ resolver: zodResolver(recruitmentSchema), }); - const { photos, handleUploadPhoto, handleDeletePhoto } = + const { photos, setImageUrls, handleUploadPhoto, handleDeletePhoto } = useUploadPhoto(UPLOAD_LIMIT); const contentLength = watch('content')?.length ?? 0; @@ -118,15 +120,23 @@ export default function VolunteersUpdatePage() { }); }; + const setVolunteersRecruitmentFormvalues = useCallback( + (recruitment: RecruitmentDetail) => { + setValue('title', recruitment.title); + setValue('startTime', new Date(recruitment.startTime)); + setValue('endTime', new Date(recruitment.endTime)); + setValue('deadline', new Date(recruitment.deadline)); + setValue('capacity', recruitment.capacity); + setValue('content', recruitment?.content ?? ''); + setImageUrls(recruitment.imageUrls); + }, + [], + ); + useEffect(() => { - setValue('title', recruitment.title); - setValue('startTime', new Date(recruitment.startTime)); - setValue('endTime', new Date(recruitment.endTime)); - setValue('deadline', new Date(recruitment.deadline)); - setValue('capacity', recruitment.capacity); - setValue('content', recruitment?.content ?? ''); setFocus('title'); - }, [recruitment, setFocus, setValue]); + setVolunteersRecruitmentFormvalues(recruitment); + }, [recruitment, setVolunteersRecruitmentFormvalues, setFocus]); if (isRecruitFetchLoading) { return

...로딩중

; From 5254a300ffb6930405c4a430e4115a9e773a63de Mon Sep 17 00:00:00 2001 From: woo hyeonji <117665863+Eosdia@users.noreply.github.com> Date: Thu, 30 Nov 2023 15:56:46 +0900 Subject: [PATCH 05/11] =?UTF-8?q?feat=20=EB=B4=89=EC=82=AC=EC=9E=90=20?= =?UTF-8?q?=EB=A7=88=EC=9D=B4=ED=8E=98=EC=9D=B4=EC=A7=80=EC=9D=98=20?= =?UTF-8?q?=EB=B4=89=EC=82=AC=ED=9B=84=EA=B8=B0=20msw=20=EC=97=B0=EA=B2=B0?= =?UTF-8?q?=20(#233)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(volunteer): 마이페이지의 봉사자가 작성한 봉사후기 UI 추가 * feat(volunteer): 봉사자가 작성한 리뷰 조회 mock api 추가 * feat(volunteer): 봉사자가 작성한 봉사리뷰 조회 api 추가 * feat(volunteer): 봉사자가 작성한 봉사리뷰 조회 msw 연결 * feat(volunteer): 리뷰수정하기 추가 * feat(volunteer): 봉사자 리뷰 삭제 mock api 추가 * fix(volunteer): 봉사자 리뷰 삭제 api 타입 수정 * feat(volunteer): 봉사자 리뷰 삭제 msw 연결, 삭제 확인 모달 추가 --- apps/volunteer/src/apis/review.ts | 2 +- apps/volunteer/src/apis/volunteer.ts | 29 +++- .../volunteer/src/mocks/handlers/volunteer.ts | 36 +++++ .../src/pages/my/_components/MyReviews.tsx | 133 ++++++++++++++++++ apps/volunteer/src/pages/my/index.tsx | 3 +- 5 files changed, 200 insertions(+), 3 deletions(-) create mode 100644 apps/volunteer/src/pages/my/_components/MyReviews.tsx diff --git a/apps/volunteer/src/apis/review.ts b/apps/volunteer/src/apis/review.ts index 3c7a446d..a5e0fd18 100644 --- a/apps/volunteer/src/apis/review.ts +++ b/apps/volunteer/src/apis/review.ts @@ -26,7 +26,7 @@ export const updateVolunteerReview = ( reqeust, ); -export const deleteVolunteerReview = (reviewId: string) => +export const deleteVolunteerReview = (reviewId: number) => axiosInstance.delete(`/volunteers/reviews/${reviewId}`); export const getVolunteerReviewsOnShelter = ( diff --git a/apps/volunteer/src/apis/volunteer.ts b/apps/volunteer/src/apis/volunteer.ts index 01f9a251..e69e522c 100644 --- a/apps/volunteer/src/apis/volunteer.ts +++ b/apps/volunteer/src/apis/volunteer.ts @@ -1,5 +1,7 @@ import axiosInstance from 'shared/apis/axiosInstance'; +import { PageInfo } from '@/types/apis/recruitment'; + export type MyInfoResponse = { volunteerId: number; volunteerEmail: string; @@ -63,4 +65,29 @@ type ApplicantsResponse = { export const getVolunteerApplicantList = () => axiosInstance.get('/volunteers/applicants'); -//TODO 봉사자가 작성한 후기 리스트 조회 +type Pagination = { + pageSize: number; + pageNumber: number; +}; + +type MyReview = { + reviewId: number; + shelterId: number; + shelterName: string; + reviewCreatedAt: string; + reviewContent: string; + reviewImageUrls: string[]; +}; + +export type MyReviewsResponse = { + pageInfo: PageInfo; + reviews: MyReview[]; +}; + +export const getMyReviewsAPI = (page: number, size: number) => + axiosInstance.get('/volunteers/me/reviews', { + params: { + page, + size, + }, + }); diff --git a/apps/volunteer/src/mocks/handlers/volunteer.ts b/apps/volunteer/src/mocks/handlers/volunteer.ts index 34af7b55..26d8ebf8 100644 --- a/apps/volunteer/src/mocks/handlers/volunteer.ts +++ b/apps/volunteer/src/mocks/handlers/volunteer.ts @@ -1,5 +1,17 @@ import { delay, http, HttpResponse } from 'msw'; +const DUMMY_MYREVIEW = { + reviewId: 32, + shelterName: '해피퍼피', + shelterId: 1, + reviewCreatedAt: '2023-03-16T18:00', + reviewContent: '시설이 너무 깨끗하고 강아지도...', + reviewImageUrls: [ + 'https://source.unsplash.com/random', + 'https://source.unsplash.com/random', + ], +}; + export const handlers = [ http.get('/volunteers/me', async () => { await delay(200); @@ -20,4 +32,28 @@ export const handlers = [ console.log(updateVolunteer); return new HttpResponse(null, { status: 204 }); }), + http.get('/volunteers/me/reviews', async ({ request }) => { + await delay(1000); + const url = new URL(request.url); + const page = url.searchParams.get('page'); + + return HttpResponse.json( + { + pageInfo: { + totalElements: 30, + hasNext: page === '3' ? false : true, + }, + reviews: Array.from({ length: 10 }, () => ({ + ...DUMMY_MYREVIEW, + reviewId: Math.random(), + })), + }, + { + status: 200, + }, + ); + }), + http.delete('/volunteers/reviews/:reviewId', async () => { + return new HttpResponse(null, { status: 204 }); + }), ]; diff --git a/apps/volunteer/src/pages/my/_components/MyReviews.tsx b/apps/volunteer/src/pages/my/_components/MyReviews.tsx new file mode 100644 index 00000000..097f9bef --- /dev/null +++ b/apps/volunteer/src/pages/my/_components/MyReviews.tsx @@ -0,0 +1,133 @@ +import { Box, Heading, Text, useDisclosure, VStack } from '@chakra-ui/react'; +import { + InfiniteData, + useMutation, + useQueryClient, + useSuspenseInfiniteQuery, +} from '@tanstack/react-query'; +import { AxiosResponse } from 'axios'; +import { Suspense, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import AlertModal from 'shared/components/AlertModal'; +import InfoSubtext from 'shared/components/InfoSubtext'; +import ReviewItem from 'shared/components/ReviewItem'; +import useIntersect from 'shared/hooks/useIntersection'; +import { createFormattedTime } from 'shared/utils/date'; + +import { deleteVolunteerReview } from '@/apis/review'; +import { getMyReviewsAPI, MyReviewsResponse } from '@/apis/volunteer'; + +function MyReviews() { + const navigate = useNavigate(); + + const queryClient = useQueryClient(); + + const { isOpen, onOpen, onClose } = useDisclosure(); + + const [deleteReviewId, setDeleteReviewId] = useState(0); + + const { + data: { pages }, + hasNextPage, + isFetchingNextPage, + fetchNextPage, + } = useSuspenseInfiniteQuery({ + queryKey: ['myreviews'], + queryFn: async ({ pageParam }) => + (await getMyReviewsAPI(pageParam, 10)).data, + initialPageParam: 1, + getNextPageParam: ({ pageInfo }, _, lastPageParam) => + pageInfo.hasNext ? lastPageParam + 1 : null, + }); + + const reviews = pages.flatMap((item) => item.reviews); + + const ref = useIntersect(async (entry, observer) => { + observer.unobserve(entry.target); + if (hasNextPage && !isFetchingNextPage) { + fetchNextPage(); + } + }); + + const deleteReveiw = useMutation({ + mutationFn: async (reviewId: number) => + await deleteVolunteerReview(reviewId), + onSuccess: (_, reviewId) => { + queryClient.setQueryData( + ['myreviews'], + (data: InfiniteData>) => ({ + ...data, + pages: data.pages.map((page) => ({ + ...page, + reviews: reviews.filter((review) => review.reviewId !== reviewId), + })), + }), + ); + setDeleteReviewId(0); + onClose(); + }, + }); + + const openDeleteModal = (reviewId: number) => { + onOpen(); + setDeleteReviewId(reviewId); + }; + + return ( + + + 작성한 후기 {pages[0].pageInfo.totalElements}개 + + + {reviews.map((review) => { + const { reviewId, shelterId } = review; + return ( + + navigate(`/shelters/${shelterId}/reviews/write/${reviewId}`) + } + onDelete={() => openDeleteModal(reviewId)} + > + + + {review.shelterName} + + + + + ); + })} + +
+ { + setDeleteReviewId(0); + onClose(); + }} + onClick={() => deleteReveiw.mutate(deleteReviewId)} + /> + + ); +} + +export default function MyReviewsTab() { + return ( + '로딩 중 입니다..'

}> + +
+ ); +} diff --git a/apps/volunteer/src/pages/my/index.tsx b/apps/volunteer/src/pages/my/index.tsx index 659afa3d..2c7d0032 100644 --- a/apps/volunteer/src/pages/my/index.tsx +++ b/apps/volunteer/src/pages/my/index.tsx @@ -3,6 +3,7 @@ import Label from 'shared/components/Label'; import ProfileInfo from 'shared/components/ProfileInfo'; import Tabs from 'shared/components/Tabs'; +import MyReviewsTab from './_components/MyReviews'; import useFetchMyVolunteer from './_hooks/useFetchMyVolunteer'; export default function MyPage() { @@ -38,7 +39,7 @@ export default function MyPage() { ], - ['작성한 봉사 후기', ], + ['작성한 봉사 후기', ], ]} /> From 3774c3159c25fc12fa4c5b40536893c805534702 Mon Sep 17 00:00:00 2001 From: Choi Won Suk Date: Thu, 30 Nov 2023 16:37:16 +0900 Subject: [PATCH 06/11] =?UTF-8?q?feat:=20=EB=B4=89=EC=82=AC=EC=9E=90=20?= =?UTF-8?q?=EB=A7=88=EC=9D=B4=ED=8E=98=EC=9D=B4=EC=A7=80=EC=97=90=EC=84=9C?= =?UTF-8?q?=20=EC=8B=A0=EC=B2=AD=ED=95=9C=20=EB=B4=89=EC=82=AC=ED=9B=84?= =?UTF-8?q?=EA=B8=B0=20UI=20=EB=B0=8F=20msw=20=EC=97=B0=EA=B2=B0=20(#236)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(volunteer): MyPage에 사용되는 applicant 상태 constant 추가 * feat(volunteer): MyPage 관련 type volunteer에 추가 * feat(volunteer): 봉사 리스트 조회 api volunteer에 추가 * feat(volunteer): Mypage의 봉사 리스트 관련 mock handler 추가 * feat(volunteer): ApplyRecruitments 컴포넌트 추가 * feat(volunteer): Mypage에 ApplyRecruitments 추가 * fix(volunteer): 봉사 후기 작성 이동 주소 변경 --- apps/volunteer/src/apis/volunteer.ts | 38 ++---- .../src/constants/applicantStatus.ts | 7 + .../volunteer/src/mocks/handlers/volunteer.ts | 37 ++++++ .../my/_components/ApplyRecruitments.tsx | 122 ++++++++++++++++++ apps/volunteer/src/pages/my/index.tsx | 3 +- apps/volunteer/src/types/apis/volunteer.ts | 29 +++++ 6 files changed, 205 insertions(+), 31 deletions(-) create mode 100644 apps/volunteer/src/constants/applicantStatus.ts create mode 100644 apps/volunteer/src/pages/my/_components/ApplyRecruitments.tsx create mode 100644 apps/volunteer/src/types/apis/volunteer.ts diff --git a/apps/volunteer/src/apis/volunteer.ts b/apps/volunteer/src/apis/volunteer.ts index e69e522c..71bb4869 100644 --- a/apps/volunteer/src/apis/volunteer.ts +++ b/apps/volunteer/src/apis/volunteer.ts @@ -1,6 +1,10 @@ import axiosInstance from 'shared/apis/axiosInstance'; import { PageInfo } from '@/types/apis/recruitment'; +import { + PagenationRequestParams, + VolunteerApplicantsResponseData, +} from '@/types/apis/volunteer'; export type MyInfoResponse = { volunteerId: number; @@ -17,20 +21,6 @@ export type MyInfoResponse = { export const getMyVolunteerInfo = () => axiosInstance.get('/volunteers/me'); -type PasswordUpdateParams = { - newPassword: string; - oldPassword: string; -}; - -export const updateVolunteerPassword = ( - passwordUpdateParams: PasswordUpdateParams, -) => { - return axiosInstance.patch( - '/volunteers/me/password', - passwordUpdateParams, - ); -}; - export type UpdateUserInfoParams = { name: string; gender: 'FEMALE' | 'MALE'; @@ -47,23 +37,11 @@ export const updateVolunteerUserInfo = ( updateUserInfoParams, ); -type Applicant = { - recruitmentId: number; - recruitmentTitle: string; - recruitmentStartTime: string; - shelterName: string; - applicantId: number; - applicantStatus: string; - applicantIsWritedReview: boolean; -}; - -type ApplicantsResponse = { - applicants: Applicant[]; -}; - //봉사자가 신청한 봉사 리스트 조회 -export const getVolunteerApplicantList = () => - axiosInstance.get('/volunteers/applicants'); +export const getVolunteerApplicants = (params: PagenationRequestParams) => + axiosInstance.get('/volunteers/applicants', { + params, + }); type Pagination = { pageSize: number; diff --git a/apps/volunteer/src/constants/applicantStatus.ts b/apps/volunteer/src/constants/applicantStatus.ts new file mode 100644 index 00000000..edc00728 --- /dev/null +++ b/apps/volunteer/src/constants/applicantStatus.ts @@ -0,0 +1,7 @@ +export const APPLICANT_STATUS = { + PENDING: { ENG: 'PENDING', KOR: '대기중', COLOR: 'YELLOW' }, + REFUSED: { ENG: 'REFUSED', KOR: '거절됨', COLOR: 'RED' }, + ATTENDANCE: { ENG: 'ATTENDANCE', KOR: '승인완료', COLOR: 'ORANGE' }, + APPROVED: { ENG: 'APPROVED', KOR: '출석완료', COLOR: 'GREEN' }, + NOSHOW: { ENG: 'NOSHOW', KOR: '불참', COLOR: 'RED' }, +} as const; diff --git a/apps/volunteer/src/mocks/handlers/volunteer.ts b/apps/volunteer/src/mocks/handlers/volunteer.ts index 26d8ebf8..eb62f228 100644 --- a/apps/volunteer/src/mocks/handlers/volunteer.ts +++ b/apps/volunteer/src/mocks/handlers/volunteer.ts @@ -1,5 +1,32 @@ import { delay, http, HttpResponse } from 'msw'; +import { Applicant } from '@/types/apis/volunteer'; + +const DUMMY_APPLY_RECRUITMENT_DATA: Applicant[] = Array.from( + { length: 6 }, + (_, i) => { + return { + shelterId: i + 1, + recruitmentId: i + 1, + applicantId: i + 1, + recruitmentTitle: '봉사자 모집합니다' + i, + shelterName: '양천구 보호소', + applicantStatus: + i === 0 + ? 'PENDING' + : i === 1 + ? 'REFUSED' + : i === 2 + ? 'ATTENDANCE' + : i === 3 || i === 4 + ? 'APPROVED' + : 'NOSHOW', + applicantIsWritedReview: i === 4 ? true : false, + recruitmentStartTime: '2023-12-29T22:24:04.565688', + }; + }, +); + const DUMMY_MYREVIEW = { reviewId: 32, shelterName: '해피퍼피', @@ -32,6 +59,16 @@ export const handlers = [ console.log(updateVolunteer); return new HttpResponse(null, { status: 204 }); }), + http.get('/volunteers/applicants', async () => { + await delay(200); + return HttpResponse.json({ + pageInfo: { + hasNext: true, + totalElements: 20, + }, + applicants: DUMMY_APPLY_RECRUITMENT_DATA, + }); + }), http.get('/volunteers/me/reviews', async ({ request }) => { await delay(1000); const url = new URL(request.url); diff --git a/apps/volunteer/src/pages/my/_components/ApplyRecruitments.tsx b/apps/volunteer/src/pages/my/_components/ApplyRecruitments.tsx new file mode 100644 index 00000000..d4cf6bb6 --- /dev/null +++ b/apps/volunteer/src/pages/my/_components/ApplyRecruitments.tsx @@ -0,0 +1,122 @@ +import { + Box, + Button, + Card, + CardBody, + Heading, + Text, + useToken, +} from '@chakra-ui/react'; +import { useSuspenseInfiniteQuery } from '@tanstack/react-query'; +import { useNavigate } from 'react-router-dom'; +import Label from 'shared/components/Label'; +import useIntersect from 'shared/hooks/useIntersection'; +import { createFormattedTime } from 'shared/utils/date'; + +import { getVolunteerApplicants } from '@/apis/volunteer'; +import { APPLICANT_STATUS } from '@/constants/applicantStatus'; +import type { Applicant } from '@/types/apis/volunteer'; + +type ApplyRecruitmentItemProps = { + applyRecruitment: Applicant; +}; + +export default function ApplyRecruitments() { + const { + data: { pages }, + hasNextPage, + isFetchingNextPage, + fetchNextPage, + } = useSuspenseInfiniteQuery({ + queryKey: ['my', 'apply', 'recruitments'], + queryFn: ({ pageParam }) => + getVolunteerApplicants({ page: pageParam, size: 10 }), + initialPageParam: 0, + getNextPageParam: ({ data: { pageInfo } }, _, lastPageParam) => + pageInfo.hasNext ? lastPageParam + 1 : null, + }); + + const totalApplyRecruitments = pages[0].data.pageInfo.totalElements; + const applyRecruitments = pages.flatMap( + ({ data: { applicants } }) => applicants, + ); + + const ref = useIntersect((entry, observer) => { + observer.unobserve(entry.target); + + if (hasNextPage && !isFetchingNextPage) { + fetchNextPage(); + } + }); + + return ( + + + {`신청한 봉사 ${totalApplyRecruitments}개`} + + {applyRecruitments.map((applyRecruitment, index) => ( + + ))} + + + ); +} + +function ApplyRecruitmentItem({ + applyRecruitment: { + applicantStatus, + recruitmentTitle, + shelterName, + recruitmentStartTime, + applicantIsWritedReview, + shelterId, + applicantId, + }, +}: ApplyRecruitmentItemProps) { + const [orange400] = useToken('colors', ['orange.400']); + const navigate = useNavigate(); + + return ( + + + + + ); +} diff --git a/apps/volunteer/src/pages/my/index.tsx b/apps/volunteer/src/pages/my/index.tsx index 2c7d0032..8fcca32b 100644 --- a/apps/volunteer/src/pages/my/index.tsx +++ b/apps/volunteer/src/pages/my/index.tsx @@ -3,6 +3,7 @@ import Label from 'shared/components/Label'; import ProfileInfo from 'shared/components/ProfileInfo'; import Tabs from 'shared/components/Tabs'; +import ApplyRecruitments from './_components/ApplyRecruitments'; import MyReviewsTab from './_components/MyReviews'; import useFetchMyVolunteer from './_hooks/useFetchMyVolunteer'; @@ -38,7 +39,7 @@ export default function MyPage() { ], + ['신청한 봉사 목록', ], ['작성한 봉사 후기', ], ]} /> diff --git a/apps/volunteer/src/types/apis/volunteer.ts b/apps/volunteer/src/types/apis/volunteer.ts new file mode 100644 index 00000000..bab04ce5 --- /dev/null +++ b/apps/volunteer/src/types/apis/volunteer.ts @@ -0,0 +1,29 @@ +import { APPLICANT_STATUS } from '@/constants/applicantStatus'; + +type PageInfo = { + totalElements: number; + hasNext: boolean; +}; + +export type PagenationRequestParams = { + page: number; + size: number; +}; + +export type ApplicantStatus = keyof typeof APPLICANT_STATUS; + +export type Applicant = { + shelterId: number; + recruitmentId: number; + recruitmentTitle: string; + recruitmentStartTime: string; + shelterName: string; + applicantId: number; + applicantStatus: ApplicantStatus; + applicantIsWritedReview: boolean; +}; + +export type VolunteerApplicantsResponseData = { + pageInfo: PageInfo; + applicants: Applicant[]; +}; From d99cf565421f1671bff93bd93ad991e98f3a4c50 Mon Sep 17 00:00:00 2001 From: Hyejin Yang Date: Thu, 30 Nov 2023 17:30:46 +0900 Subject: [PATCH 07/11] =?UTF-8?q?feat(shelter):=20=EB=B3=B4=ED=98=B8?= =?UTF-8?q?=EC=86=8C=20=EB=A7=88=EC=9D=B4=ED=8E=98=EC=9D=B4=EC=A7=80=20?= =?UTF-8?q?=ED=86=A0=EA=B8=80=20=EB=B2=84=ED=8A=BC=20=EB=94=94=EC=9E=90?= =?UTF-8?q?=EC=9D=B8=20=EC=88=98=EC=A0=95=20=EB=B0=8F=20useMutation=20?= =?UTF-8?q?=EC=82=AC=EC=9A=A9=ED=95=98=EB=8F=84=EB=A1=9D=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20(#242)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(shelter): 보호소 마이페이지 토글 버튼 디자인 수정 * feat(shelter): useMyPage hook 에서 useMutation 사용하도록 수정 * feat(shelter): 보호소 앱의 queryClient stale time 50초로 변경 --- apps/shelter/src/App.tsx | 8 +++- apps/shelter/src/pages/my/_hooks/useMyPage.ts | 48 +++++++++++-------- apps/shelter/src/pages/my/index.tsx | 5 ++ 3 files changed, 39 insertions(+), 22 deletions(-) diff --git a/apps/shelter/src/App.tsx b/apps/shelter/src/App.tsx index e222a5e1..53a5f598 100644 --- a/apps/shelter/src/App.tsx +++ b/apps/shelter/src/App.tsx @@ -7,7 +7,13 @@ import theme from 'shared/theme'; import { router } from '@/routes'; -const queryClient = new QueryClient(); +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + staleTime: 50000, + }, + }, +}); export default function App() { return ( diff --git a/apps/shelter/src/pages/my/_hooks/useMyPage.ts b/apps/shelter/src/pages/my/_hooks/useMyPage.ts index 2d781c66..1c18b875 100644 --- a/apps/shelter/src/pages/my/_hooks/useMyPage.ts +++ b/apps/shelter/src/pages/my/_hooks/useMyPage.ts @@ -1,5 +1,5 @@ -import { useQuery } from '@tanstack/react-query'; -import { useState } from 'react'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { ChangeEvent, useEffect, useState } from 'react'; import { getShelterInfoAPI, updateAddressStatusAPI } from '@/apis/shelter'; import { ShelterInfo } from '@/types/apis/shetler'; @@ -33,34 +33,40 @@ const createProfile = (response: ShelterInfo): ShelterProfile => { }; export const useMyPage = () => { - const [isAddressPublic, setIsAddressPublic] = useState(false); + const queryClient = useQueryClient(); - const updateAddressStatus = async () => { - try { - await updateAddressStatusAPI(!isAddressPublic); - setIsAddressPublic(!isAddressPublic); - } catch (error) { - console.error(error); - } - }; + const { mutate: updateAddressStatus } = useMutation({ + mutationFn: async (event: ChangeEvent) => { + updateAddressStatusAPI(!event.target.checked); + }, + onSuccess: () => { + const isOpenedAddress = !isAddressPublic; + setIsAddressPublic(isOpenedAddress); + queryClient.setQueryData(['shelterProfile'], (data: ShelterProfile) => ({ + ...data, + isAddressPublic: isOpenedAddress, + })); + }, + }); const { data } = useQuery({ queryKey: ['shelterProfile'], queryFn: async () => { const response = (await getShelterInfoAPI()).data; - setIsAddressPublic(response.shelterIsOpenedAddress); - return createProfile(response); }, - initialData: { - shelterName: '', - email: '', - phoneNumber: '', - sparePhoneNumber: '', - shelterAddress: '', - isAddressPublic: false, - }, }); + const [isAddressPublic, setIsAddressPublic] = useState( + data?.isAddressPublic ?? false, + ); + + useEffect(() => { + if (data) { + const { isAddressPublic } = data; + setIsAddressPublic(isAddressPublic); + } + }, [data]); + return { shelterProfile: data, isAddressPublic, updateAddressStatus }; }; diff --git a/apps/shelter/src/pages/my/index.tsx b/apps/shelter/src/pages/my/index.tsx index 7648b4d1..32222008 100644 --- a/apps/shelter/src/pages/my/index.tsx +++ b/apps/shelter/src/pages/my/index.tsx @@ -12,6 +12,10 @@ export default function MyPage() { const navigate = useNavigate(); const { shelterProfile, isAddressPublic, updateAddressStatus } = useMyPage(); + if (!shelterProfile) { + return null; + } + const { shelterName, email, phoneNumber, sparePhoneNumber, shelterAddress } = shelterProfile; @@ -33,6 +37,7 @@ export default function MyPage() { From 087e835e17f0ebc1fd5fdba7c3c8b1a4ce9ae7e8 Mon Sep 17 00:00:00 2001 From: Choi Won Suk Date: Thu, 30 Nov 2023 17:30:53 +0900 Subject: [PATCH 08/11] =?UTF-8?q?feat(shared):=20validations=20=EC=A0=84?= =?UTF-8?q?=ED=99=94=EB=B2=88=ED=98=B8=20=ED=98=95=EC=8B=9D=EC=97=90=20'-'?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80=20(#243)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/shared/utils/validations.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/shared/utils/validations.ts b/packages/shared/utils/validations.ts index bc98b2e9..6b167f42 100644 --- a/packages/shared/utils/validations.ts +++ b/packages/shared/utils/validations.ts @@ -40,12 +40,12 @@ export const isOpenedAddress = z.boolean(); export const phoneNumber = z .string() .min(1, '보호소 전화번호 정보는 필수입니다') - .regex(/^\d{2,3}\d{3,4}\d{4}$/, '전화번호 형식이 올바르지 않습니다'); + .regex(/^\d{2,3}-\d{3,4}-\d{4}$/, '전화번호 형식이 올바르지 않습니다'); export const sparePhoneNumber = z.union([ z.literal(''), z .string() - .regex(/^\d{2,3}\d{3,4}\d{4}$/, '전화번호 형식이 올바르지 않습니다'), + .regex(/^\d{2,3}-\d{3,4}-\d{4}$/, '전화번호 형식이 올바르지 않습니다'), ]); export const gender = z.enum(['FEMALE', 'MALE']); export const birthDate = z From 9e6b8670eb10951f0c4e58c0716002d488a656b4 Mon Sep 17 00:00:00 2001 From: Dongja Date: Thu, 30 Nov 2023 17:36:58 +0900 Subject: [PATCH 09/11] =?UTF-8?q?feat=20:=20=EB=A6=AC=EB=B7=B0=20=EC=8A=A4?= =?UTF-8?q?=EC=BC=88=EB=A0=88=ED=86=A4=20(#238)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(shared): review Item skeleton 컴포넌트 생성 * feat(shelter): review list skeleton 컴포넌트 추가 및 보호소앱 리뷰 페이지에 적용 * feat(shelter): 리뷰 페이지에서 봉사자 프로필 페이지로 이동하는 기능 추가 --- apps/shelter/src/apis/shelter.ts | 1 + apps/shelter/src/mocks/handlers/shelter.ts | 2 +- .../src/pages/my/reviews/VolunteerProfile.tsx | 4 ++- apps/shelter/src/pages/my/reviews/index.tsx | 13 +++++-- .../shared/components/ReviewItemSkeleton.tsx | 35 +++++++++++++++++++ .../components/ReviewItemSkeletonList.tsx | 19 ++++++++++ 6 files changed, 69 insertions(+), 5 deletions(-) create mode 100644 packages/shared/components/ReviewItemSkeleton.tsx create mode 100644 packages/shared/components/ReviewItemSkeletonList.tsx diff --git a/apps/shelter/src/apis/shelter.ts b/apps/shelter/src/apis/shelter.ts index d512eeb7..3d2b17e4 100644 --- a/apps/shelter/src/apis/shelter.ts +++ b/apps/shelter/src/apis/shelter.ts @@ -47,6 +47,7 @@ export const getShelterReviewList = (pageParams: PageParams) => volunteerTemperature: number; volunteerReviewCount: number; volunteerImageUrl: string; + volunteerId: number; }[]; }>(`/shelters/me/reviews`, { params: pageParams, diff --git a/apps/shelter/src/mocks/handlers/shelter.ts b/apps/shelter/src/mocks/handlers/shelter.ts index 8418542f..64be3ba1 100644 --- a/apps/shelter/src/mocks/handlers/shelter.ts +++ b/apps/shelter/src/mocks/handlers/shelter.ts @@ -37,7 +37,7 @@ export const handlers = [ return HttpResponse.json({ status: 200 }); }), http.get('/shelters/me/reviews', async () => { - await delay(200); + await delay(3000); return HttpResponse.json( { pageInfo: { diff --git a/apps/shelter/src/pages/my/reviews/VolunteerProfile.tsx b/apps/shelter/src/pages/my/reviews/VolunteerProfile.tsx index 9701bbcd..d557d386 100644 --- a/apps/shelter/src/pages/my/reviews/VolunteerProfile.tsx +++ b/apps/shelter/src/pages/my/reviews/VolunteerProfile.tsx @@ -9,6 +9,7 @@ type VolunteerProfileprops = { volunteerReviewCount: number; volunteerImageUrl: string; reviewCreatedAt: string; + onClickNextButton: VoidFunction; }; export default function VolunteerProfile({ @@ -17,6 +18,7 @@ export default function VolunteerProfile({ volunteerReviewCount, volunteerImageUrl, reviewCreatedAt, + onClickNextButton, }: VolunteerProfileprops) { return ( @@ -26,7 +28,7 @@ export default function VolunteerProfile({ {volunteerName} - + diff --git a/apps/shelter/src/pages/my/reviews/index.tsx b/apps/shelter/src/pages/my/reviews/index.tsx index cb62093e..b991d0b3 100644 --- a/apps/shelter/src/pages/my/reviews/index.tsx +++ b/apps/shelter/src/pages/my/reviews/index.tsx @@ -1,6 +1,8 @@ import { Box, Heading, VStack } from '@chakra-ui/react'; import { Suspense } from 'react'; +import { useNavigate } from 'react-router-dom'; import ReviewItem from 'shared/components/ReviewItem'; +import ReviewItemSkeletonList from 'shared/components/ReviewItemSkeletonList'; import useIntersect from 'shared/hooks/useIntersection'; import { createFormattedTime } from 'shared/utils/date'; @@ -10,7 +12,7 @@ import VolunteerProfile from './VolunteerProfile'; const PAGE_SIZE = 10; function Reviews() { - //TODO 봉사자 옆에 화살표 버튼 클릭시 봉사자 프로필 페이지로 가는 기능추가 + const navigate = useNavigate(); const { data: { pages }, @@ -28,6 +30,10 @@ function Reviews() { } }); + const goVolunteerProfile = (id: number) => { + navigate(`/volunteers/profile/${id}`); + }; + return ( goVolunteerProfile(review.volunteerId)} reviewCreatedAt={createFormattedTime( new Date(review.reviewCreatedAt), 'YY.MM.DD.', @@ -62,14 +69,14 @@ function Reviews() { ))} - + {isFetchingNextPage ? : } ); } export default function MyReviewsPage() { return ( - 글목록 로딩중...

}> + }> ); diff --git a/packages/shared/components/ReviewItemSkeleton.tsx b/packages/shared/components/ReviewItemSkeleton.tsx new file mode 100644 index 00000000..892b8923 --- /dev/null +++ b/packages/shared/components/ReviewItemSkeleton.tsx @@ -0,0 +1,35 @@ +import { + Card, + HStack, + Skeleton, + SkeletonCircle, + SkeletonText, + Stack, +} from '@chakra-ui/react'; + +export default function ReviewItemSkeleton() { + return ( + + + + + + + + + + + + + + + ); +} diff --git a/packages/shared/components/ReviewItemSkeletonList.tsx b/packages/shared/components/ReviewItemSkeletonList.tsx new file mode 100644 index 00000000..64ded630 --- /dev/null +++ b/packages/shared/components/ReviewItemSkeletonList.tsx @@ -0,0 +1,19 @@ +import { Box, Skeleton, VStack } from '@chakra-ui/react'; + +import ReviewItemSkeleton from './ReviewItemSkeleton'; + +export default function ReviewItemSkeletonList({ + showTitle = false, +}: { + showTitle?: boolean; +}) { + return ( + + {showTitle && } + + + + + + ); +} From 816d9ecc317da2df8df8dfd72626af62627de6b4 Mon Sep 17 00:00:00 2001 From: Dongja Date: Thu, 30 Nov 2023 17:37:18 +0900 Subject: [PATCH 10/11] =?UTF-8?q?feat=20:=20withlogin,=20layout=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=20(#244)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(volunteer): withlogin 컴포넌트 early return 추가 * feat(shared): 보호소 어플 회원가입 페이지에 접근가능하도록 허용, layout console 제거 * fix(shared): 봉사자 앱, 보호소 앱 react strict mode 제거 --- apps/shelter/src/main.tsx | 7 +------ apps/volunteer/src/components/WithLogin.tsx | 2 +- apps/volunteer/src/main.tsx | 7 +------ packages/shared/layout/index.tsx | 3 +++ 4 files changed, 6 insertions(+), 13 deletions(-) diff --git a/apps/shelter/src/main.tsx b/apps/shelter/src/main.tsx index f6444594..f2480c6d 100644 --- a/apps/shelter/src/main.tsx +++ b/apps/shelter/src/main.tsx @@ -1,4 +1,3 @@ -import React from 'react'; import ReactDOM from 'react-dom/client'; import App from './App.tsx'; @@ -16,9 +15,5 @@ async function deferRender() { } deferRender().then(() => { - ReactDOM.createRoot(document.getElementById('root')!).render( - - - , - ); + ReactDOM.createRoot(document.getElementById('root')!).render(); }); diff --git a/apps/volunteer/src/components/WithLogin.tsx b/apps/volunteer/src/components/WithLogin.tsx index 1cf3b81e..4f0af1a1 100644 --- a/apps/volunteer/src/components/WithLogin.tsx +++ b/apps/volunteer/src/components/WithLogin.tsx @@ -6,7 +6,7 @@ export default function WithLogin({ children }: { children: ReactNode }) { const { user } = useAuthStore(); if (user) { - children; + return children; } return ; diff --git a/apps/volunteer/src/main.tsx b/apps/volunteer/src/main.tsx index d7ba7b9a..fb8d28e7 100644 --- a/apps/volunteer/src/main.tsx +++ b/apps/volunteer/src/main.tsx @@ -1,4 +1,3 @@ -import React from 'react'; import ReactDOM from 'react-dom/client'; import App from './App.tsx'; @@ -16,9 +15,5 @@ async function deferRender() { } deferRender().then(() => { - ReactDOM.createRoot(document.getElementById('root')!).render( - - - , - ); + ReactDOM.createRoot(document.getElementById('root')!).render(); }); diff --git a/packages/shared/layout/index.tsx b/packages/shared/layout/index.tsx index f79f2ae2..995f65ce 100644 --- a/packages/shared/layout/index.tsx +++ b/packages/shared/layout/index.tsx @@ -21,6 +21,9 @@ export default function Layout({ appType }: LayoutProps) { if (appType === 'VOLUNTEER_APP' && pathname === '/') { navigate('/volunteers'); } else if (appType === 'SHELTER_APP') { + if (pathname === '/signup' || pathname === '/signin') { + return; + } navigate('/signin'); } From df3b1b8802e186eebf21d0bf671a3d35d51f2822 Mon Sep 17 00:00:00 2001 From: woo hyeonji <117665863+Eosdia@users.noreply.github.com> Date: Thu, 30 Nov 2023 17:50:37 +0900 Subject: [PATCH 11/11] =?UTF-8?q?feat:=20=EB=B4=89=EC=82=AC=EB=AA=A8?= =?UTF-8?q?=EC=A7=91=20=EC=83=81=EC=84=B8=ED=8E=98=EC=9D=B4=EC=A7=80=20?= =?UTF-8?q?=EB=B3=B4=ED=98=B8=EC=86=8C=ED=94=84=EB=A1=9C=ED=95=84=20?= =?UTF-8?q?=EC=9D=B4=EB=8F=99,=20=EB=B4=89=EC=82=AC=20=EC=8B=A0=EC=B2=AD?= =?UTF-8?q?=20msw=20=EC=B6=94=EA=B0=80=20(#245)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor(volunteer): 봉사모집상세조회, 보호소 간단정보조회 fetch hook 분리 * refactor(volunteer): 봉사모집상세조회 데이터 받아오기 * feat(volunteer): 봉사신청 mock api 추가 * fix(volunteer): 봉사신청 api 수정 * feat(volunteer): 봉사신청 msw 연결 * fix(volunteer): 채팅하기 버튼 제거 * feat(volunteer): 보호소 정보 클릭하면 보호소 프로필 페이지로 이동 * feat(volunteer): 봉사신청 자동마감 된 경우 추가 --- apps/volunteer/src/apis/recruitment.ts | 4 +- .../src/mocks/handlers/recruitment.ts | 5 + .../_hooks/useFetchRecruitmentDetail.ts | 9 + .../_hooks/useFetchSimpleShelterInfo.ts | 11 ++ .../detail/_hooks/useFetchVolunteerDetail.ts | 74 ------- .../src/pages/volunteers/detail/index.tsx | 181 +++++++++++------- 6 files changed, 138 insertions(+), 146 deletions(-) create mode 100644 apps/volunteer/src/pages/volunteers/detail/_hooks/useFetchRecruitmentDetail.ts create mode 100644 apps/volunteer/src/pages/volunteers/detail/_hooks/useFetchSimpleShelterInfo.ts delete mode 100644 apps/volunteer/src/pages/volunteers/detail/_hooks/useFetchVolunteerDetail.ts diff --git a/apps/volunteer/src/apis/recruitment.ts b/apps/volunteer/src/apis/recruitment.ts index cb3541b6..b367051b 100644 --- a/apps/volunteer/src/apis/recruitment.ts +++ b/apps/volunteer/src/apis/recruitment.ts @@ -5,8 +5,8 @@ import { RecruitmentsRequest, } from '@/types/apis/recruitment'; -export const ApplyRecruitments = (recruitmentId: string) => - axiosInstance.post(`/recruitments/${recruitmentId}/apply`); +export const applyRecruitments = (recruitmentId: number) => + axiosInstance.post(`/volunteers/recruitments/${recruitmentId}/apply`); export const getRecruitments = (request: Partial) => axiosInstance.get( diff --git a/apps/volunteer/src/mocks/handlers/recruitment.ts b/apps/volunteer/src/mocks/handlers/recruitment.ts index dbdb35be..d6df7485 100644 --- a/apps/volunteer/src/mocks/handlers/recruitment.ts +++ b/apps/volunteer/src/mocks/handlers/recruitment.ts @@ -54,4 +54,9 @@ export const handlers = [ { status: 200 }, ); }), + http.post('/volunteers/recruitments/:recruitmentId/apply', async () => { + return new HttpResponse(null, { + status: 204, + }); + }), ]; diff --git a/apps/volunteer/src/pages/volunteers/detail/_hooks/useFetchRecruitmentDetail.ts b/apps/volunteer/src/pages/volunteers/detail/_hooks/useFetchRecruitmentDetail.ts new file mode 100644 index 00000000..c705e9d2 --- /dev/null +++ b/apps/volunteer/src/pages/volunteers/detail/_hooks/useFetchRecruitmentDetail.ts @@ -0,0 +1,9 @@ +import { useSuspenseQuery } from '@tanstack/react-query'; +import { getRecruitmentDetail } from 'shared/apis/common/Recruitments'; + +const useFetchRecruitmentDetail = (recruitmentId: number) => + useSuspenseQuery({ + queryKey: ['recruitment', recruitmentId], + queryFn: async () => (await getRecruitmentDetail(recruitmentId)).data, + }); +export default useFetchRecruitmentDetail; diff --git a/apps/volunteer/src/pages/volunteers/detail/_hooks/useFetchSimpleShelterInfo.ts b/apps/volunteer/src/pages/volunteers/detail/_hooks/useFetchSimpleShelterInfo.ts new file mode 100644 index 00000000..6d7cfd64 --- /dev/null +++ b/apps/volunteer/src/pages/volunteers/detail/_hooks/useFetchSimpleShelterInfo.ts @@ -0,0 +1,11 @@ +import { useSuspenseQuery } from '@tanstack/react-query'; + +import { getSimpleShelterProfile } from '@/apis/shelter'; + +const useFetchSimpleShelterInfo = (shelterId: number) => + useSuspenseQuery({ + queryKey: ['shelter', 'simpleProfile', shelterId], + queryFn: async () => (await getSimpleShelterProfile(shelterId)).data, + }); + +export default useFetchSimpleShelterInfo; diff --git a/apps/volunteer/src/pages/volunteers/detail/_hooks/useFetchVolunteerDetail.ts b/apps/volunteer/src/pages/volunteers/detail/_hooks/useFetchVolunteerDetail.ts deleted file mode 100644 index 55a8b42d..00000000 --- a/apps/volunteer/src/pages/volunteers/detail/_hooks/useFetchVolunteerDetail.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { useQuery } from '@tanstack/react-query'; -import { getRecruitmentDetail } from 'shared/apis/common/Recruitments'; -import { - createFormattedTime, - createWeekDayLocalString, -} from 'shared/utils/date'; - -import { getSimpleShelterProfile } from '@/apis/shelter'; - -const useFetchVolunteerDetail = (recruitmentId: number) => - useQuery({ - queryKey: ['volunteer', recruitmentId], - queryFn: async () => { - const { shelterId, ...recruitmentInfo } = ( - await getRecruitmentDetail(recruitmentId) - ).data; - const shelterSimpleInfo = (await getSimpleShelterProfile(shelterId)).data; - - return { - ...recruitmentInfo, - shelterInfo: { shelterId, ...shelterSimpleInfo }, - }; - }, - select: (data) => { - const startDate = new Date(data.recruitmentStartTime); - const endDate = new Date(data.recruitmentEndTime); - const deadLine = new Date(data.recruitmentDeadline); - - return { - imageUrls: data.recruitmentImageUrls, - title: data.recruitmentTitle, - content: data.recruitmentContent, - applicant: data.recruitmentApplicantCount, - capacity: data.recruitmentCapacity, - volunteerDay: `${createFormattedTime( - startDate, - )}(${createWeekDayLocalString(startDate)})`, - recruitmentDeadline: `${createFormattedTime( - deadLine, - )}(${createWeekDayLocalString(deadLine)}) ${createFormattedTime( - deadLine, - 'hh:mm', - )}`, - volunteerStartTime: createFormattedTime(startDate, 'hh:mm'), - volunteerEndTime: createFormattedTime(endDate, 'hh:mm'), - recruitmentCreatedAt: createFormattedTime( - new Date(data.recruitmentCreatedAt), - ), - recruitmentIsClosed: data.recruitmentIsClosed, - shelterInfo: data.shelterInfo, - }; - }, - initialData: { - recruitmentTitle: '', - recruitmentApplicantCount: 0, - recruitmentCapacity: 0, - recruitmentContent: '', - recruitmentStartTime: '', - recruitmentEndTime: '', - recruitmentIsClosed: false, - recruitmentDeadline: '', - recruitmentCreatedAt: '', - recruitmentUpdatedAt: '', - recruitmentImageUrls: [], - shelterInfo: { - shelterId: 0, - shelterName: '', - shelterImageUrl: '', - shelterAddress: '', - shelterEmail: '', - }, - }, - }); -export default useFetchVolunteerDetail; diff --git a/apps/volunteer/src/pages/volunteers/detail/index.tsx b/apps/volunteer/src/pages/volunteers/detail/index.tsx index ace21194..3e96d174 100644 --- a/apps/volunteer/src/pages/volunteers/detail/index.tsx +++ b/apps/volunteer/src/pages/volunteers/detail/index.tsx @@ -5,120 +5,160 @@ import { HStack, Text, useDisclosure, + useToast, VStack, } from '@chakra-ui/react'; -import { useEffect, useState } from 'react'; +import { useMutation } from '@tanstack/react-query'; +import { useState } from 'react'; import { useNavigate, useParams } from 'react-router-dom'; import AlertModal from 'shared/components/AlertModal'; import ImageCarousel from 'shared/components/ImageCarousel'; import InfoTextList from 'shared/components/InfoTextList'; -import { LabelProps } from 'shared/components/Label'; +import Label from 'shared/components/Label'; import LabelText from 'shared/components/LabelText'; import ProfileInfo from 'shared/components/ProfileInfo'; -import { getDDay } from 'shared/utils/date'; +import { + createFormattedTime, + createWeekDayLocalString, + getDDay, +} from 'shared/utils/date'; + +import { applyRecruitments } from '@/apis/recruitment'; -import useFetchVolunteerDetail from './_hooks/useFetchVolunteerDetail'; +import useFetchVolunteerDetail from './_hooks/useFetchRecruitmentDetail'; +import useFetchSimpleShelterInfo from './_hooks/useFetchSimpleShelterInfo'; export default function VolunteersDetailPage() { + const toast = useToast(); const navigate = useNavigate(); - const { id } = useParams(); + + const { id } = useParams<{ id: string }>(); const recruitmentId = Number(id); + const { isOpen, onOpen, onClose } = useDisclosure(); - const [label, setLabel] = useState({ - labelTitle: '모집중', - type: 'GREEN', + + const { data } = useFetchVolunteerDetail(recruitmentId); + + const [isRecruitmentClosed, setIsRecruitmentClosed] = useState( + data.recruitmentIsClosed, + ); + + const volunteerDay = new Date(data.recruitmentStartTime); + const deadline = new Date(data.recruitmentDeadline); + const createdAt = new Date(data.recruitmentCreatedAt); + const updatedAt = new Date(data.recruitmentUpdatedAt); + + const { data: shelter } = useFetchSimpleShelterInfo(data.shelterId); + + const { mutate: applyRecruitment } = useMutation({ + mutationFn: async () => await applyRecruitments(recruitmentId), + onSuccess: () => { + toast({ + position: 'top', + description: '봉사 신청이 완료되었습니다.', + status: 'success', + duration: 1500, + }); + }, + onError: (error) => { + if (error.response?.status === 409) { + toast({ + position: 'top', + description: '봉사 모집이 마감되었습니다.', + status: 'error', + duration: 1500, + }); + setIsRecruitmentClosed(!isRecruitmentClosed); + } + }, }); - const { data } = useFetchVolunteerDetail(3); - - const { - imageUrls, - title, - content, - applicant, - capacity, - volunteerDay, - recruitmentDeadline, - volunteerStartTime, - volunteerEndTime, - recruitmentCreatedAt, - recruitmentIsClosed, - shelterInfo, - } = data; - const { shelterName, shelterImageUrl, shelterAddress, shelterEmail } = - shelterInfo; - - useEffect(() => { - if (recruitmentIsClosed) { - setLabel({ labelTitle: '마감완료', type: 'GRAY' }); - } - }, [recruitmentIsClosed]); - - const goChatting = () => { - //TODO 채팅방 생성 API - navigate(`/chattings/${recruitmentId}`); + const goShelterProfilePage = () => { + navigate(`/shelters/profile/${data.shelterId}`); }; const onApplyRecruitment = () => { onClose(); - //TODO 봉사신청 API - //TODO 봉사신청완료 toast + applyRecruitment(); }; return ( - + - + {isRecruitmentClosed ? ( + - {content} + {data.recruitmentContent} - + + + - - +