From 62a6036042d8433182bc5d152c98a3214afd0b52 Mon Sep 17 00:00:00 2001 From: cjeongmin Date: Sun, 12 May 2024 23:03:22 +0900 Subject: [PATCH] feat: add modify post flow (#75) --- src/app/lib/providers/AuthProvider.tsx | 20 ++- src/app/pages/shared-post-page.tsx | 49 ++++-- src/app/pages/shared-posts-page.tsx | 19 ++- src/app/pages/writing-post-page.tsx | 103 ++++++++---- src/features/shared/shared.atom.ts | 36 +++++ src/features/shared/shared.hook.ts | 215 +++++++++++-------------- src/features/shared/shared.type.ts | 5 +- 7 files changed, 273 insertions(+), 174 deletions(-) create mode 100644 src/features/shared/shared.atom.ts diff --git a/src/app/lib/providers/AuthProvider.tsx b/src/app/lib/providers/AuthProvider.tsx index 8d61cc3d59..f7cb64a8bb 100644 --- a/src/app/lib/providers/AuthProvider.tsx +++ b/src/app/lib/providers/AuthProvider.tsx @@ -5,6 +5,7 @@ import { usePathname, useRouter } from 'next/navigation'; import { useLayoutEffect, useState, useCallback } from 'react'; import { + getUserData, postTokenRefresh, useAuthActions, useAuthValue, @@ -13,7 +14,7 @@ import { load, remove } from '@/shared/storage'; export function AuthProvider({ children }: { children: React.ReactNode }) { const auth = useAuthValue(); - const { login } = useAuthActions(); + const { setAuthUserData, login } = useAuthActions(); const router = useRouter(); const pathName = usePathname(); @@ -57,12 +58,27 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { postTokenRefresh(refreshToken) .then(({ data }) => { handleLoginSuccess(data); + getUserData() + .then(res => { + setAuthUserData(res.data); + }) + .catch(err => { + console.error(err); + }); }) .catch(handleLoginError) .finally(() => { setIsLoading(false); }); - }, [pathName, auth, isLoading, handleLoginSuccess, handleLoginError, router]); + }, [ + pathName, + auth, + isLoading, + handleLoginError, + router, + handleLoginSuccess, + setAuthUserData, + ]); useLayoutEffect(() => { checkAndRefreshToken(); diff --git a/src/app/pages/shared-post-page.tsx b/src/app/pages/shared-post-page.tsx index c63384032f..d288033853 100644 --- a/src/app/pages/shared-post-page.tsx +++ b/src/app/pages/shared-post-page.tsx @@ -1,5 +1,6 @@ 'use client'; +import { useRouter } from 'next/navigation'; import { useEffect, useRef, useState } from 'react'; import styled from 'styled-components'; @@ -7,12 +8,17 @@ import { Bookmark, CircularProfileImage } from '@/components'; import { ImageGrid } from '@/components/shared-post-page'; import { useAuthValue, useUserData } from '@/features/auth'; import { useCreateChatRoom } from '@/features/chat'; +import { fromAddrToCoord } from '@/features/geocoding'; import { useFollowUser, useFollowingListData, useUnfollowUser, } from '@/features/profile'; -import { useScrapSharedPost, useSharedPost } from '@/features/shared'; +import { + useScrapSharedPost, + useSharedPost, + useSharedPostProps, +} from '@/features/shared'; import { getAge } from '@/shared'; const styles = { @@ -399,9 +405,13 @@ const styles = { export function SharedPostPage({ postId }: { postId: number }) { const auth = useAuthValue(); + const [, setMap] = useState(null); const mapRef = useRef(null); + const router = useRouter(); + const { setStateWithPost } = useSharedPostProps(); + const [selected, setSelected] = useState< | { memberId: string; @@ -441,17 +451,24 @@ export function SharedPostPage({ postId }: { postId: number }) { ); useEffect(() => { - if (mapRef.current != null) { - const center = new naver.maps.LatLng(37.6090857, 126.9966865); - setMap( - new naver.maps.Map(mapRef.current, { - center, - disableKineticPan: false, - scrollWheel: false, - }), + if (sharedPost?.data.address.roadAddress != null) { + fromAddrToCoord({ query: sharedPost?.data.address.roadAddress }).then( + res => { + const address = res.data.addresses.shift(); + if (address != null && mapRef.current != null) { + const center = new naver.maps.LatLng(+address.y, +address.x); + setMap( + new naver.maps.Map(mapRef.current, { + center, + disableKineticPan: false, + scrollWheel: false, + }), + ); + } + }, ); } - }, [mapRef]); + }, [sharedPost]); const [roomName, setRoomName] = useState(''); @@ -549,7 +566,17 @@ export function SharedPostPage({ postId }: { postId: number }) {

{sharedPost.data.address.roadAddress}

- 수정하기 + {sharedPost.data.publisherAccount.memberId === + auth?.user?.memberId && ( + { + setStateWithPost(sharedPost); + router.push('/shared/writing'); + }} + > + 수정하기 + + )} diff --git a/src/app/pages/shared-posts-page.tsx b/src/app/pages/shared-posts-page.tsx index bab5695161..c885fe0279 100644 --- a/src/app/pages/shared-posts-page.tsx +++ b/src/app/pages/shared-posts-page.tsx @@ -18,7 +18,11 @@ import { } from '@/entities/shared-posts-filter'; import { useAuthActions, useAuthValue, useUserData } from '@/features/auth'; import { useRecommendationMate } from '@/features/recommendation'; -import { usePaging, useSharedPosts } from '@/features/shared'; +import { + usePaging, + useSharedPostProps, + useSharedPosts, +} from '@/features/shared'; import { type GetSharedPostsDTO } from '@/features/shared/'; const styles = { @@ -120,6 +124,8 @@ export function SharedPostsPage() { useState(null); const { setAuthUserData } = useAuthActions(); + const { reset: resetSharedPostProps } = useSharedPostProps(); + const { filter, derivedFilter, reset: resetFilter } = useSharedPostsFilter(); const { data: userData } = useUserData(auth?.accessToken != null); @@ -178,9 +184,14 @@ export function SharedPostsPage() { {(selected === 'hasRoom' || selected === 'dormitory') && ( - - 작성하기 - + { + resetSharedPostProps(); + router.push('/shared/writing'); + }} + > + 작성하기 + )} {selected === 'hasRoom' || selected === 'dormitory' ? ( diff --git a/src/app/pages/writing-post-page.tsx b/src/app/pages/writing-post-page.tsx index b6b34586fb..d5077aad7b 100644 --- a/src/app/pages/writing-post-page.tsx +++ b/src/app/pages/writing-post-page.tsx @@ -10,22 +10,22 @@ import { MateSearchBox, } from '@/components/writing-post-page'; import { + AdditionalInfoTypeValue, CountTypeValue, - type DealType, DealTypeValue, - RoomTypeValue, - type RoomType, FloorTypeValue, - type FloorType, - AdditionalInfoTypeValue, LivingRoomTypeValue, + RoomTypeValue, + type DealType, + type FloorType, + type RoomType, } from '@/entities/shared-posts-filter'; import { useAuthValue } from '@/features/auth'; import { getImageURL, putImage } from '@/features/image'; import { useCreateSharedPost, - useCreateSharedPostProps, usePostMateCardInputSection, + useSharedPostProps, type ImageFile, } from '@/features/shared'; import { useToast } from '@/features/toast'; @@ -68,9 +68,11 @@ const styles = { .column { display: flex; + width: 100%; flex-direction: column; gap: 1rem; - flex: 1 0 0; + + overflow-x: auto; } `, mateCardContainer: styled.div` @@ -284,19 +286,21 @@ const styles = { `, images: styled.div` display: flex; + width: fit-content; align-items: center; align-self: stretch; gap: 1rem; overflow-x: auto; `, - image: styled.img` + image: styled.div<{ $url: string }>` width: 14.4375rem; height: 9.875rem; background: #ededed; - object-fit: cover; - object-position: center; + background-image: ${({ $url }) => `url("${$url}")`}; + background-position: center; + background-size: cover; cursor: pointer; `, @@ -417,6 +421,7 @@ export function WritingPostPage() { useState(false); const { + mode, title, content, images, @@ -426,18 +431,12 @@ export function WritingPostPage() { selectedOptions, selectedExtraOptions, expectedMonthlyFee, - setTitle, - setContent, - setImages, - setMateLimit, - setHouseSize, - setAddress, - setExpectedMonthlyFee, + setSharedPostProps, handleOptionClick, handleExtraOptionClick, isOptionSelected, isExtraOptionSelected, - } = useCreateSharedPostProps(); + } = useSharedPostProps(); const { gender, @@ -462,13 +461,19 @@ export function WritingPostPage() { const handleTitleInputChanged = ( event: React.ChangeEvent, ) => { - setTitle(event.target.value); + setSharedPostProps(prev => ({ + ...prev, + title: event.target.value, + })); }; const handleContentInputChanged = ( event: React.ChangeEvent, ) => { - setContent(event.target.value); + setSharedPostProps(prev => ({ + ...prev, + content: event.target.value, + })); }; const handleImageInputClicked = () => { @@ -482,13 +487,21 @@ export function WritingPostPage() { file, url: URL.createObjectURL(file), extension: `.${file.type.split('/')[1]}`, + uploaded: false, + })); + + setSharedPostProps(prev => ({ + ...prev, + images: [...prev.images, ...imagesArray], })); - setImages(prevImages => [...prevImages, ...imagesArray]); } }; const handleRemoveImage = (removeImage: ImageFile) => { - setImages(prev => prev.filter(image => image.url !== removeImage.url)); + setSharedPostProps(prev => ({ + ...prev, + images: prev.images.filter(image => image.url !== removeImage.url), + })); }; const convertToNumber = (value: string) => { @@ -548,33 +561,43 @@ export function WritingPostPage() { (async () => { try { const getResults = await Promise.allSettled( - images.map(async ({ extension, file }) => { + images.map(async ({ url, extension, file, uploaded }) => { + if (uploaded || extension == null) return { url, uploaded }; const result = await getImageURL(extension); return { ...result.data.data, + uploaded, file, }; }), ); const urls = getResults.reduce< - Array<{ file: File; fileName: string; url: string }> + Array<{ + file?: File; + fileName?: string; + url: string; + uploaded: boolean; + }> >((prev, result) => { if (result.status === 'rejected') return prev; return prev.concat(result.value); }, []); const putResults = await Promise.allSettled( - urls.map(async url => { - await putImage(url.url, url.file); - return { fileName: url.fileName }; + urls.map(async ({ url, fileName, file, uploaded }) => { + if (uploaded) return { fileName }; + + if (file != null) await putImage(url, file); + return { fileName }; }), ); const uploadedImages = putResults.reduce< Array<{ fileName: string; isThumbNail: boolean; order: number }> >((prev, result) => { - if (result.status === 'rejected') return prev; + if (result.status === 'rejected' || result.value.fileName == null) + return prev; return prev.concat({ fileName: result.value.fileName, isThumbNail: prev.length === 0, @@ -665,7 +688,7 @@ export function WritingPostPage() { 기본 정보 - 작성하기 + {mode === 'create' ? '작성하기' : '수정하기'} 제목 @@ -694,7 +717,10 @@ export function WritingPostPage() { {showLocationSearchBox && ( { - setAddress(selectedAddress); + setSharedPostProps(prev => ({ + ...prev, + address: selectedAddress, + })); setShowLocationSearchBox(false); }} setHidden={() => { @@ -720,7 +746,7 @@ export function WritingPostPage() { {images.map(image => ( { handleRemoveImage(image); }} @@ -746,7 +772,10 @@ export function WritingPostPage() { value={mateLimit} onChange={event => { handleNumberInput(event.target.value, value => { - setMateLimit(value); + setSharedPostProps(prev => ({ + ...prev, + mateLimit: value, + })); }); }} $width={3} @@ -833,7 +862,10 @@ export function WritingPostPage() { value={expectedMonthlyFee} onChange={event => { handleNumberInput(event.target.value, value => { - setExpectedMonthlyFee(value); + setSharedPostProps(prev => ({ + ...prev, + expectedMonthlyFee: value, + })); }); }} $width={3} @@ -933,7 +965,10 @@ export function WritingPostPage() { value={houseSize} onChange={event => { handleNumberInput(event.target.value, value => { - setHouseSize(value); + setSharedPostProps(prev => ({ + ...prev, + houseSize: value, + })); }); }} $width={2} diff --git a/src/features/shared/shared.atom.ts b/src/features/shared/shared.atom.ts new file mode 100644 index 0000000000..0a47e5a2c8 --- /dev/null +++ b/src/features/shared/shared.atom.ts @@ -0,0 +1,36 @@ +import { atom } from 'recoil'; + +import { + type SelectedExtraOptions, + type ImageFile, + type SelectedOptions, +} from './shared.type'; + +import { type NaverAddress } from '@/features/geocoding'; + +export const sharedPostPropState = atom<{ + mode: 'create' | 'modify'; + title: string; + content: string; + images: ImageFile[]; + address?: NaverAddress; + mateLimit: number; + expectedMonthlyFee: number; + houseSize: number; + selectedExtraOptions: SelectedExtraOptions; + selectedOptions: SelectedOptions; +}>({ + key: 'sharedPostPropState', + default: { + mode: 'create', + title: '', + content: '', + images: [], + address: undefined, + mateLimit: 0, + expectedMonthlyFee: 0, + houseSize: 0, + selectedExtraOptions: {}, + selectedOptions: {}, + }, +}); diff --git a/src/features/shared/shared.hook.ts b/src/features/shared/shared.hook.ts index 9b8bd87fc4..d5b1e26e42 100644 --- a/src/features/shared/shared.hook.ts +++ b/src/features/shared/shared.hook.ts @@ -1,6 +1,7 @@ import { useMutation, useQuery } from '@tanstack/react-query'; import { type AxiosResponse } from 'axios'; import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useRecoilState, useResetRecoilState } from 'recoil'; import { createSharedPost, @@ -13,16 +14,16 @@ import { scrapDormitoryPost, scrapPost, } from './shared.api'; +import { sharedPostPropState } from './shared.atom'; +import { type GetSharedPostDTO } from './shared.dto'; import { type CreateSharedPostProps, type GetSharedPostsProps, - type ImageFile, - type SelectedExtraOptions, type SelectedOptions, } from './shared.type'; +import { fromAddrToCoord } from '../geocoding'; import { useAuthValue } from '@/features/auth'; -import { type NaverAddress } from '@/features/geocoding'; import { useDebounce } from '@/shared/debounce'; import { type FailureDTO, type SuccessBaseDTO } from '@/shared/types'; @@ -93,126 +94,98 @@ export const usePaging = ({ ); }; -export const useCreateSharedPostProps = () => { - const [title, setTitle] = useState(''); - const [content, setContent] = useState(''); - const [images, setImages] = useState([]); - const [address, setAddress] = useState(null); - - const [mateLimit, setMateLimit] = useState(0); - const [expectedMonthlyFee, setExpectedMonthlyFee] = useState(0); - - const [houseSize, setHouseSize] = useState(0); - const [selectedExtraOptions, setSelectedExtraOptions] = - useState({}); - const [selectedOptions, setSelectedOptions] = useState({}); +export const useSharedPostProps = () => { + const [state, setState] = useRecoilState(sharedPostPropState); + const reset = useResetRecoilState(sharedPostPropState); + + const setStateWithPost = ({ data }: GetSharedPostDTO) => { + fromAddrToCoord({ query: data.address.roadAddress }) + .then(res => { + const address = res.data.addresses.shift(); + if (address != null) setState(prev => ({ ...prev, address })); + }) + .catch(err => { + console.error(err); + }); - const handleExtraOptionClick = useCallback((option: string) => { - setSelectedExtraOptions(prevSelectedOptions => ({ - ...prevSelectedOptions, - [option]: !prevSelectedOptions[option], + let roomCount = '1개'; + if (data.roomInfo.numberOfRoom === 2) roomCount = '2개'; + else if (data.roomInfo.numberOfRoom === 3) roomCount = '3개 이상'; + + let restRoomCount = '1개'; + if (data.roomInfo.numberOfBathRoom === 2) restRoomCount = '2개'; + else if (data.roomInfo.numberOfBathRoom === 3) restRoomCount = '3개'; + + setState({ + mode: 'modify', + title: data.title, + content: data.content, + images: data.roomImages.map(({ fileName }) => ({ + url: fileName, + uploaded: true, + })), + mateLimit: data.roomInfo.recruitmentCapacity, + expectedMonthlyFee: data.roomInfo.expectedPayment, + houseSize: data.roomInfo.size, + selectedOptions: { + roomType: data.roomInfo.roomType, + roomCount, + budget: data.roomInfo.rentalType, + floorType: data.roomInfo.floorType, + livingRoom: data.roomInfo.hasLivingRoom ? '유' : '무', + restRoomCount, + }, + selectedExtraOptions: { + 주차가능: data.roomInfo.extraOption.canPark, + 에어컨: data.roomInfo.extraOption.hasAirConditioner, + 냉장고: data.roomInfo.extraOption.hasRefrigerator, + 세탁기: data.roomInfo.extraOption.hasWasher, + '베란다/테라스': data.roomInfo.extraOption.hasTerrace, + }, + }); + }; + + const handleOptionClick = ( + optionName: keyof SelectedOptions, + item: string, + ) => { + console.log(state.selectedOptions, optionName, item); + setState(prev => ({ + ...prev, + selectedOptions: { + ...prev.selectedOptions, + [optionName]: prev.selectedOptions[optionName] === item ? null : item, + }, })); - }, []); - - const handleOptionClick = useCallback( - (optionName: keyof SelectedOptions, item: string) => { - setSelectedOptions(prevState => ({ - ...prevState, - [optionName]: prevState[optionName] === item ? null : item, - })); - }, - [], - ); - - const isOptionSelected = useCallback( - (optionName: keyof SelectedOptions, item: string) => - selectedOptions[optionName] === item, - [selectedOptions], - ); - - const isExtraOptionSelected = useCallback( - (item: string) => selectedExtraOptions[item], - [selectedExtraOptions], - ); - - const isPostCreatable = useMemo( - () => - images.length > 0 && - title.trim().length > 0 && - content.trim().length > 0 && - selectedOptions.budget != null && - expectedMonthlyFee > 0 && - selectedOptions.roomType != null && - houseSize > 0 && - selectedOptions.roomCount != null && - selectedOptions.restRoomCount != null && - selectedOptions.livingRoom != null && - mateLimit > 0 && - address != null, - [ - images, - title, - content, - selectedOptions, - expectedMonthlyFee, - houseSize, - mateLimit, - address, - ], - ); - - return useMemo( - () => ({ - title, - setTitle, - content, - setContent, - images, - setImages, - address, - setAddress, - mateLimit, - setMateLimit, - expectedMonthlyFee, - setExpectedMonthlyFee, - houseSize, - setHouseSize, - selectedExtraOptions, - setSelectedExtraOptions, - selectedOptions, - setSelectedOptions, - handleOptionClick, - handleExtraOptionClick, - isOptionSelected, - isExtraOptionSelected, - isPostCreatable, - }), - [ - title, - setTitle, - content, - setContent, - images, - setImages, - address, - setAddress, - mateLimit, - setMateLimit, - expectedMonthlyFee, - setExpectedMonthlyFee, - houseSize, - setHouseSize, - selectedExtraOptions, - setSelectedExtraOptions, - selectedOptions, - setSelectedOptions, - handleOptionClick, - handleExtraOptionClick, - isOptionSelected, - isExtraOptionSelected, - isPostCreatable, - ], - ); + }; + + const handleExtraOptionClick = (option: string) => { + console.log(state.selectedExtraOptions, option); + setState(prev => ({ + ...prev, + selectedExtraOptions: { + ...prev.selectedExtraOptions, + [option]: !prev.selectedExtraOptions[option], + }, + })); + }; + + const isOptionSelected = (optionName: keyof SelectedOptions, item: string) => + state.selectedOptions[optionName] === item; + + const isExtraOptionSelected = (item: string) => + state.selectedExtraOptions[item]; + + return { + ...state, + setSharedPostProps: setState, + setStateWithPost, + reset, + handleOptionClick, + handleExtraOptionClick, + isOptionSelected, + isExtraOptionSelected, + }; }; export const usePostMateCardInputSection = () => { diff --git a/src/features/shared/shared.type.ts b/src/features/shared/shared.type.ts index e809f5ba44..f8c40e5965 100644 --- a/src/features/shared/shared.type.ts +++ b/src/features/shared/shared.type.ts @@ -33,8 +33,9 @@ export type SelectedExtraOptions = Record; export interface ImageFile { url: string; - file: File; - extension: string; + uploaded: boolean; + file?: File; + extension?: string; } export interface CreateSharedPostProps {