diff --git a/.eslintrc.json b/.eslintrc.json index dd21620..7ebebe7 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -53,8 +53,9 @@ ], "no-empty-pattern": ["warn", { "allowObjectPatternsAsParameters": true }], "@typescript-eslint/no-unused-vars": "error", + "@typescript-eslint/no-floating-promises": "off", "@typescript-eslint/explicit-function-return-type": "off", - "@typescript-eslint/no-empty-function": ["warn", {}], + "@typescript-eslint/no-empty-function": ["off", {}], "react/no-array-index-key": "warn", "react/require-default-props": "off", "react/jsx-no-useless-fragment": "off", diff --git a/public/kakao-login.png b/public/kakao-login.png new file mode 100644 index 0000000..3c4f2c6 Binary files /dev/null and b/public/kakao-login.png differ diff --git a/public/landing-page-image.png b/public/landing-page-image.png new file mode 100644 index 0000000..d83d886 Binary files /dev/null and b/public/landing-page-image.png differ diff --git a/public/naver-login.png b/public/naver-login.png new file mode 100644 index 0000000..7c72753 Binary files /dev/null and b/public/naver-login.png differ diff --git a/src/app/layout.tsx b/src/app/layout.tsx index edc0121..b934776 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -2,6 +2,7 @@ import type { Metadata } from 'next'; import { Inter } from 'next/font/google'; import React from 'react'; import './globals.scss'; +import '@/app/lib/axios-interceptor'; import { AuthProvider, diff --git a/src/app/lib/axios-interceptor.ts b/src/app/lib/axios-interceptor.ts new file mode 100644 index 0000000..e15039e --- /dev/null +++ b/src/app/lib/axios-interceptor.ts @@ -0,0 +1,56 @@ +'use client'; + +import axios, { type AxiosRequestConfig, isAxiosError } from 'axios'; + +import { postTokenRefresh } from '@/features/auth'; +import { load, save } from '@/shared/storage'; + +interface AxiosRequestConfigWithRetryCount extends AxiosRequestConfig { + retryCount?: number; +} + +axios.interceptors.response.use( + response => response, + async error => { + if (!isAxiosError(error)) return await Promise.reject(error); + + const refreshToken = load({ type: 'local', key: 'refreshToken' }); + const config = error.config as AxiosRequestConfigWithRetryCount; + if ( + error.response?.status === 401 && + refreshToken != null && + (config?.retryCount ?? 0) < 3 + ) { + config.retryCount = (config?.retryCount ?? 0) + 1; + try { + const response = await postTokenRefresh(refreshToken); + + axios.defaults.headers.common.Authorization = `Bearer ${response.data.accessToken}`; + save({ + type: 'local', + key: 'refreshToken', + value: response.data.refreshToken, + }); + save({ + type: 'local', + key: 'expiresIn', + value: `${response.data.expiresIn}`, + }); + + const token = response.data.accessToken; + const newConfig = { + ...config, + headers: { + ...config.headers, + Authorization: `Bearer ${token}`, + }, + }; + + return await axios(newConfig); + } catch (refreshError) { + return await Promise.reject(refreshError); + } + } + return await Promise.reject(error); + }, +); diff --git a/src/app/lib/providers/AuthProvider.tsx b/src/app/lib/providers/AuthProvider.tsx index 8114cef..c937280 100644 --- a/src/app/lib/providers/AuthProvider.tsx +++ b/src/app/lib/providers/AuthProvider.tsx @@ -1,5 +1,6 @@ 'use client'; +import { isAxiosError } from 'axios'; import { usePathname, useRouter } from 'next/navigation'; import { useEffect } from 'react'; @@ -8,7 +9,7 @@ import { useAuthActions, useAuthValue, } from '@/features/auth'; -import { load } from '@/shared/storage'; +import { load, remove } from '@/shared/storage'; export function AuthProvider({ children }: { children: React.ReactNode }) { const auth = useAuthValue(); @@ -22,26 +23,28 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { return; } - if (auth === null) { + if (auth == null) { const refreshToken = load({ type: 'local', key: 'refreshToken' }); - if (refreshToken !== null) { + if (refreshToken != null) { postTokenRefresh(refreshToken) .then(({ data }) => { login({ accessToken: data.accessToken, - refreshToken, + refreshToken: data.refreshToken, expiresIn: data.expiresIn, }); }) - .catch(err => { - console.error(err); - router.replace('/'); + .catch((err: Error) => { + if (isAxiosError(err)) { + remove({ type: 'local', key: 'refreshToken' }); + if (pathName !== '/') router.replace('/'); + } }); } else { router.replace('/'); } } - }, [auth, login, router, pathName]); + }); return <>{children}; } diff --git a/src/app/pages/landing-page.tsx b/src/app/pages/landing-page.tsx index 908298d..1de81ee 100644 --- a/src/app/pages/landing-page.tsx +++ b/src/app/pages/landing-page.tsx @@ -169,10 +169,7 @@ export function LandingPage() { return ( - +

공동주거생활의 A to Z

@@ -183,18 +180,12 @@ export function LandingPage() { - kakao + kakao - naver + naver diff --git a/src/app/pages/setting-page.tsx b/src/app/pages/setting-page.tsx index 846c130..78137fc 100644 --- a/src/app/pages/setting-page.tsx +++ b/src/app/pages/setting-page.tsx @@ -133,8 +133,9 @@ const styles = { }; interface SelectedState { - smoking: string | undefined; - room: string | undefined; + smoking?: string; + room?: string; + mateAge?: string; } interface UserProps { @@ -167,25 +168,22 @@ export function SettingPage({ cardId }: { cardId: number }) { }, [user.data]); const card = useUserCard(cardId); - const [features, setFeatures] = useState(null); + const [features, setFeatures] = useState(undefined); useEffect(() => { if (isMySelf) { if (card !== undefined) { - const featuresData = card.data?.data.myFeatures ?? null; + const featuresData = card.data?.data.myFeatures ?? undefined; setFeatures(featuresData); } } }, [card, isMySelf]); - const [selectedState, setSelectedState] = useState({ - smoking: undefined, - room: undefined, - }); + const [selectedState, setSelectedState] = useState({}); useEffect(() => { if (isMySelf) { - if (features !== null) { + if (features != null) { setSelectedState({ ...selectedState, smoking: features[0].split(':')[1], @@ -204,7 +202,7 @@ export function SettingPage({ cardId }: { cardId: number }) { useEffect(() => { if (isMySelf) { - if (features !== null) { + if (features != null) { const initialOptions: SelectedOptions = {}; const optionsString = features[3].split(':')[1]; const budgetIdx = optionsString.indexOf('['); diff --git a/src/app/pages/user-input-page.tsx b/src/app/pages/user-input-page.tsx index 0a98451..74d9d4e 100644 --- a/src/app/pages/user-input-page.tsx +++ b/src/app/pages/user-input-page.tsx @@ -476,7 +476,7 @@ export function UserInputPage() { gender={user?.gender} birthYear={user?.birthYear} location={undefined} - vitalFeatures={null} + vitalFeatures={undefined} onFeatureChange={handleFeatureChange} onLocationChange={setLocation} onMateAgeChange={() => {}} @@ -488,7 +488,7 @@ export function UserInputPage() { mbti={undefined} major={undefined} budget={undefined} - optionFeatures={null} + optionFeatures={undefined} onFeatureChange={handleOptionClick} onMbtiChange={setMbti} onMajorChange={setMajor} @@ -502,7 +502,7 @@ export function UserInputPage() { gender={user?.gender} birthYear={undefined} location={locationInput} - vitalFeatures={null} + vitalFeatures={undefined} onFeatureChange={handleMateFeatureChange} onLocationChange={setLocation} onMateAgeChange={setMateAge} @@ -514,7 +514,7 @@ export function UserInputPage() { mbti={undefined} major={undefined} budget={undefined} - optionFeatures={null} + optionFeatures={undefined} onFeatureChange={handleMateOptionClick} onMbtiChange={setMateMbti} onMajorChange={setMateMajor} diff --git a/src/app/pages/writing-post-page.tsx b/src/app/pages/writing-post-page.tsx index 0f31064..c91ddca 100644 --- a/src/app/pages/writing-post-page.tsx +++ b/src/app/pages/writing-post-page.tsx @@ -4,13 +4,28 @@ import { useRouter } from 'next/navigation'; import React, { useEffect, useRef, useState } from 'react'; import styled from 'styled-components'; +import { UserInputSection } from '@/components'; import { LocationSearchBox, MateSearchBox, } from '@/components/writing-post-page'; -import { type NaverAddress } from '@/features/geocoding'; +import { useAuthValue } from '@/features/auth'; import { getImageURL, putImage } from '@/features/image'; -import { useCreateSharedPost } from '@/features/shared'; +import { + useCreateSharedPost, + useCreateSharedPostProps, + usePostMateCardInputSection, + type ImageFile, +} from '@/features/shared'; +import { useToast } from '@/features/toast'; +import { + type RentalType, + RoomTypeValue, + RentalTypeValue, + type RoomType, + FloorTypeValue, + type FloorType, +} from '@/shared/types'; const styles = { pageContainer: styled.div` @@ -35,6 +50,73 @@ const styles = { border-radius: 16px; background: #fff; `, + essentialInfoContainer: styled.div` + display: flex; + flex: 1 0 0; + width: 100%; + flex-direction: column; + gap: 1rem; + `, + essentialRow: styled.div` + display: flex; + width: 100%; + flex-direction: row; + gap: 1rem; + + .column { + display: flex; + flex-direction: column; + gap: 1rem; + flex: 1 0 0; + } + `, + mateCardContainer: styled.div` + display: flex; + width: 100%; + flex-direction: column; + gap: 1rem; + + button { + all: unset; + cursor: pointer; + + display: flex; + width: fit-content; + padding: 0.5rem 1rem; + justify-content: center; + align-items: center; + gap: 0.5rem; + + border-radius: 0.5rem; + background: #ededed; + + color: #000; + font-family: 'Noto Sans KR'; + font-size: 1rem; + font-style: normal; + font-weight: 400; + line-height: normal; + } + + button[class~='edit'] { + display: flex; + width: fit-content; + padding: 0.5rem 1rem; + justify-content: center; + align-items: center; + gap: 0.5rem; + + border-radius: 0.5rem; + background: #e15637; + + color: #eee; + font-family: 'Noto Sans KR'; + font-size: 1rem; + font-style: normal; + font-weight: 700; + line-height: normal; + } + `, row: styled.div` display: flex; justify-content: space-between; @@ -52,9 +134,35 @@ const styles = { `, captionRow: styled.div` display: flex; + flex-direction: row; align-items: flex-end; gap: 1rem; align-self: stretch; + + .caption { + color: var(--Black, #35373a); + font-family: 'Noto Sans KR'; + font-size: 1rem; + font-style: normal; + font-weight: 400; + line-height: normal; + } + `, + dealInfoContainer: styled.div` + display: flex; + flex-direction: column; + justify-content: center; + align-items: flex-start; + gap: 1rem; + align-self: stretch; + `, + roomInfoContainer: styled.div` + display: flex; + flex-direction: column; + justify-content: center; + align-items: flex-start; + gap: 1rem; + align-self: stretch; `, caption: styled.span` color: rgba(53, 55, 58, 0.5); @@ -114,10 +222,11 @@ const styles = { border-radius: 8px; background: #ededed; `, - contentInput: styled.input` + contentInput: styled.textarea` all: unset; display: flex; + height: 100%; padding: 1rem; flex-direction: column; justify-content: center; @@ -291,79 +400,84 @@ const styles = { `, }; -const DealOptions = ['월세', '전세']; -const RoomOptions = ['원룸', '빌라/투룸이상', '아파트', '오피스텔']; +const DealOptions = { 월세: 'MONTHLY', 전세: 'JEONSE' }; +const RoomOptions = { + 원룸: 'ONE_ROOM', + '빌라/투룸이상': 'TWO_ROOM_VILLA', + 아파트: 'APT', + 오피스텔: 'OFFICE_TEL', +}; const LivingRoomOptions = ['유', '무']; -const RoomCountOptions = ['1개', '2개', '3개 이상']; -const RestRoomCountOptions = ['1개', '2개', '3개 이상']; -const FloorOptions = ['지상', '반지하', '옥탑']; -const AdditionalOptions = [ - '주차가능', - '에어컨', - '냉장고', - '세탁기', - '베란다/테라스', -]; +const RoomCountOptions = { '1개': 1, '2개': 2, '3개 이상': 3 }; +const RestRoomCountOptions = { '1개': 1, '2개': 2, '3개 이상': 3 }; +const FloorOptions = { + 지상: 'GROUND', + 반지하: 'SEMI_BASEMENT', + 옥탑: 'PENTHOUSE', +}; +const AdditionalOptions = { + canPark: '주차가능', + hasAirConditioner: '에어컨', + hasRefrigerator: '냉장고', + hasWasher: '세탁기', + hasTerrace: '베란다/테라스', +}; interface ButtonActiveProps { $isSelected: boolean; } -interface SelectedOptions { - budget?: string; - roomType?: string; - livingRoom?: string; - roomCount?: string; - restRoomCount?: string; - floorType?: string; -} - -type SelectedExtraOptions = Record; - -interface ImageFile { - url: string; - file: File; - extension: string; -} - export function WritingPostPage() { const router = useRouter(); - const [images, setImages] = useState([]); const imageInputRef = useRef(null); - const [selectedExtraOptions, setSelectedExtraOptions] = - useState({}); - const [showMateSearchBox, setShowMateSearchBox] = useState(false); - const [selectedOptions, setSelectedOptions] = useState({}); - const [title, setTitle] = useState(''); - const [content, setContent] = useState(''); - const [mateLimit, setMateLimit] = useState(0); - const [expectedMonthlyFee, setExpectedMonthlyFee] = useState(0); - const [houseSize, setHouseSize] = useState(0); - - const [address, setAddress] = useState(null); + const [showMateCardForm, setShowMateCardForm] = useState(false); const [showLocationSearchBox, setShowLocationSearchBox] = useState(false); - const { mutate } = useCreateSharedPost(); + const { + title, + content, + images, + mateLimit, + houseSize, + address, + selectedOptions, + selectedExtraOptions, + expectedMonthlyFee, + setTitle, + setContent, + setImages, + setMateLimit, + setHouseSize, + setAddress, + setExpectedMonthlyFee, + handleOptionClick, + handleExtraOptionClick, + isOptionSelected, + isExtraOptionSelected, + } = useCreateSharedPostProps(); + + const { + gender, + birthYear, + mbti, + major, + budget, + derivedFeatures, + setBirthYear, + setMbti, + setMajor, + setBudget, + handleEssentialFeatureChange, + handleOptionalFeatureChange, + } = usePostMateCardInputSection(); - const handleExtraOptionClick = (option: string) => { - setSelectedExtraOptions(prevSelectedOptions => ({ - ...prevSelectedOptions, - [option]: !prevSelectedOptions[option], - })); - }; + const { mutate } = useCreateSharedPost(); + const { createToast } = useToast(); - const handleOptionClick = ( - optionName: keyof SelectedOptions, - item: string, - ) => { - setSelectedOptions(prevState => ({ - ...prevState, - [optionName]: prevState[optionName] === item ? null : item, - })); - }; + const auth = useAuthValue(); const handleTitleInputChanged = ( event: React.ChangeEvent, @@ -372,7 +486,7 @@ export function WritingPostPage() { }; const handleContentInputChanged = ( - event: React.ChangeEvent, + event: React.ChangeEvent, ) => { setContent(event.target.value); }; @@ -417,86 +531,143 @@ export function WritingPostPage() { }; const handleCreatePost = (event: React.MouseEvent) => { + createToast({ message: '생성 버튼 클릭', option: { duration: 1000 } }); + // if (!isPostCreatable || !isMateCardCreatable) return; + + const rentalType = selectedOptions.budget; + const { roomType } = selectedOptions; + const { floorType } = selectedOptions; + + if ( + rentalType == null || + roomType == null || + floorType == null || + address == null || + selectedOptions.roomCount == null || + !(selectedOptions.roomCount in RoomCountOptions) || + selectedOptions.restRoomCount == null || + !(selectedOptions.restRoomCount in RestRoomCountOptions) + ) + return; + + const numberOfRoomOption = selectedOptions.roomCount as + | '1개' + | '2개' + | '3개 이상'; + const numberOfRoom = RoomCountOptions[numberOfRoomOption]; + + const numberOfBathRoomOption = selectedOptions.restRoomCount as + | '1개' + | '2개' + | '3개 이상'; + const numberOfBathRoom = RestRoomCountOptions[numberOfBathRoomOption]; + + const rentalTypeValue = RentalTypeValue[rentalType as RentalType]; + const roomTypeValue = RoomTypeValue[roomType as RoomType]; + const floorTypeValue = FloorTypeValue[floorType as FloorType]; + (async () => { - if (images.length > 0) { - try { - const getResults = await Promise.allSettled( - images.map(async ({ extension, file }) => { - const result = await getImageURL(extension); - return { - ...result.data.data, - file, - }; - }), - ); - - const urls = getResults.reduce< - Array<{ file: File; fileName: string; url: string }> - >((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 }; - }), - ); - - const uploadedImages = putResults.reduce< - Array<{ fileName: string; isThumbNail: boolean; order: number }> - >((prev, result) => { - if (result.status === 'rejected') return prev; - return prev.concat({ - fileName: result.value.fileName, - isThumbNail: prev.length === 0, - order: prev.length + 1, - }); - }, []); - - mutate( - { - imageFilesData: uploadedImages, - postData: { content, title }, - transactionData: { - rentalType: '0', - price: 100000, - monthlyFee: 10000, - managementFee: 1000, - }, - roomDetailData: { - roomType: '0', - size: 5, - numberOfRoom: 1, - recruitmentCapacity: 2, - }, - locationData: { - city: 'SEOUL', - oldAddress: 'test old address', - roadAddress: 'test road address', - stationName: 'mokdong', - stationTime: 10, - busStopTime: 3, - schoolName: 'kookmin', - schoolTime: 20, - convenienceStoreTime: 2, - }, + try { + const getResults = await Promise.allSettled( + images.map(async ({ extension, file }) => { + const result = await getImageURL(extension); + return { + ...result.data.data, + file, + }; + }), + ); + + const urls = getResults.reduce< + Array<{ file: File; fileName: string; url: string }> + >((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 }; + }), + ); + + const uploadedImages = putResults.reduce< + Array<{ fileName: string; isThumbNail: boolean; order: number }> + >((prev, result) => { + if (result.status === 'rejected') return prev; + return prev.concat({ + fileName: result.value.fileName, + isThumbNail: prev.length === 0, + order: prev.length + 1, + }); + }, []); + + mutate( + { + imageFilesData: uploadedImages, + postData: { title, content }, + transactionData: { + rentalType: rentalTypeValue, + expectedPayment: expectedMonthlyFee, }, - { - onSuccess: () => { - router.back(); + roomDetailData: { + roomType: roomTypeValue, + floorType: floorTypeValue, + size: houseSize, + numberOfRoom, + numberOfBathRoom, + hasLivingRoom: selectedOptions.livingRoom === '유', + recruitmentCapacity: mateLimit, + extraOption: { + canPark: selectedExtraOptions.canPark ?? false, + hasAirConditioner: + selectedExtraOptions.hasAirConditioner ?? false, + hasRefrigerator: selectedExtraOptions.hasRefrigerator ?? false, + hasWasher: selectedExtraOptions.hasWasher ?? false, + hasTerrace: selectedExtraOptions.hasTerrace ?? false, }, - onError: () => {}, }, - ); - } catch (error) { - console.error(error); - } + locationData: { + oldAddress: address?.jibunAddress, + roadAddress: address?.roadAddress, + }, + roomMateCardData: { + location: address?.roadAddress, + features: derivedFeatures, + }, + participationMemberIds: + auth?.user != null ? [auth.user.memberId] : [], + }, + { + onSuccess: () => { + createToast({ + message: '게시글이 정상적으로 업로드되었습니다.', + option: { + duration: 3000, + }, + }); + router.back(); + }, + onError: () => { + createToast({ + message: '게시글 업로드에 실패했습니다.', + option: { + duration: 3000, + }, + }); + }, + }, + ); + } catch (error) { + createToast({ + message: '게시글 업로드에 실패했습니다.', + option: { + duration: 3000, + }, + }); } - })().catch((error: Error) => { - console.error(error); - }); + })(); }; useEffect( @@ -511,225 +682,294 @@ export function WritingPostPage() { return ( - - 기본 정보 - - 작성하기 - - - 제목 - - 상세 정보 - - - 사진 - 최소 2장 이상 업로드 - - - {images.map(image => ( - + + 기본 정보 + + 작성하기 + + + 제목 + + 위치 정보 + + { - handleRemoveImage(image); + setShowLocationSearchBox(true); + }} + > + + 위치 찾기 + + + 상세 주소: + + + {address?.roadAddress ?? '주소를 입력해주세요.'} + + + {showLocationSearchBox && ( + { + setAddress(selectedAddress); + setShowLocationSearchBox(false); }} - /> - ))} - - - - - 인원 - - { - handleNumberInput(event.target.value, value => { - setMateLimit(value); - }); - }} - $width={3} - /> - - - 메이트 - - { - setShowMateSearchBox(true); - }} - /> - {showMateSearchBox && ( - { - setShowMateSearchBox(false); + setShowLocationSearchBox(false); }} /> )} - - 거래 정보 - 거래 방식 - - {DealOptions.map(option => ( - - { - handleOptionClick('budget', option); - }} - /> - {option} - - ))} - - 희망 메이트 월 분담금 - - { - handleNumberInput(event.target.value, value => { - setExpectedMonthlyFee(value); - }); - }} - $width={3} - /> - 만원 - - 방 정보 - - - {FloorOptions.map(option => ( - - { - handleOptionClick('floorType', option); - }} - /> - {option} - - ))} - - 추가 옵션 - - {AdditionalOptions.map(option => ( - - { - handleExtraOptionClick(option); - }} - /> - {option} - - ))} - - 방 종류 - - {RoomOptions.map(option => ( - - { - handleOptionClick('roomType', option); - }} - /> - {option} - - ))} - - 거실 - - {LivingRoomOptions.map(option => ( - - { - handleOptionClick('livingRoom', option); - }} + +

+ 상세 정보 + - {option} - - ))} - - 방 개수 - - {RoomCountOptions.map(option => ( - - { - handleOptionClick('roomCount', option); - }} - /> - {option} - - ))} - - 화장실 개수 - - {RestRoomCountOptions.map(option => ( - - { - handleOptionClick('restRoomCount', option); +
+
+ + 사진 + 최소 2장 이상 업로드 + + + {images.map(image => ( + { + handleRemoveImage(image); + }} + /> + ))} + + + + +
+ + +
+ 모집 할 인원 + + { + handleNumberInput(event.target.value, value => { + setMateLimit(value); + }); + }} + $width={3} + /> + + +
+
+ 메이트 + + { + setShowMateSearchBox(true); + }} + /> + {showMateSearchBox && ( + { + setShowMateSearchBox(false); + }} + /> + )} + +
+
+ + 메이트 카드 + + {showMateCardForm && ( + { + if ( + optionName === 'room' || + optionName === 'smoking' || + optionName === 'mateAge' + ) { + handleEssentialFeatureChange(optionName, option); + } }} + onOptionChange={handleOptionalFeatureChange} + onLocationChange={() => {}} + onMateAgeChange={setBirthYear} + onMbtiChange={setMbti} + onMajorChange={setMajor} + onBudgetChange={setBudget} /> - {option} - - ))} - - 전체 면적 - - { - handleNumberInput(event.target.value, value => { - setHouseSize(value); - }); - }} - $width={2} - /> - - - 위치 정보 - - 상세 주소 - {address?.roadAddress} - - { - setShowLocationSearchBox(true); - }} - > - - 위치 찾기 - - {showLocationSearchBox && ( - { - setAddress(selectedAddress); - }} - setHidden={() => { - setShowLocationSearchBox(false); - }} - /> - )} + )} + + + + 거래 정보 + 거래 방식 + + {Object.entries(DealOptions).map(([option, value]) => ( + + { + handleOptionClick('budget', value); + }} + /> + {option} + + ))} + + 희망 메이트 월 분담금 + + { + handleNumberInput(event.target.value, value => { + setExpectedMonthlyFee(value); + }); + }} + $width={3} + /> + 만원 + + + + 방 정보 + + + {Object.entries(FloorOptions).map(([option, value]) => ( + + { + handleOptionClick('floorType', value); + }} + /> + {option} + + ))} + + 추가 옵션 + + {Object.entries(AdditionalOptions).map(([option, value]) => ( + + { + handleExtraOptionClick(value); + }} + /> + {value} + + ))} + + 방 종류 + + {Object.entries(RoomOptions).map(([option, value]) => ( + + { + handleOptionClick('roomType', value); + }} + /> + {option} + + ))} + + 거실 + + {LivingRoomOptions.map(option => ( + + { + handleOptionClick('livingRoom', option); + }} + /> + {option} + + ))} + + 방 개수 + + {Object.keys(RoomCountOptions).map(option => ( + + { + handleOptionClick('roomCount', option); + }} + /> + {option} + + ))} + + 화장실 개수 + + {Object.keys(RestRoomCountOptions).map(option => ( + + { + handleOptionClick('restRoomCount', option); + }} + /> + {option} + + ))} + + 전체 면적 + + { + handleNumberInput(event.target.value, value => { + setHouseSize(value); + }); + }} + $width={2} + /> + + + ); diff --git a/src/components/NavigationBar.tsx b/src/components/NavigationBar.tsx index d3b6b2c..8a00cf3 100644 --- a/src/components/NavigationBar.tsx +++ b/src/components/NavigationBar.tsx @@ -18,10 +18,11 @@ import { load } from '@/shared/storage'; const styles = { container: styled.nav` display: flex; + flex: 1; + min-width: 1440px; width: 100%; height: 4.5rem; padding: 1rem 11.25rem; - flex-shrink: 0; align-items: center; justify-content: space-between; diff --git a/src/components/UserInputSection.tsx b/src/components/UserInputSection.tsx index 2f5b1b1..e693e37 100644 --- a/src/components/UserInputSection.tsx +++ b/src/components/UserInputSection.tsx @@ -24,24 +24,23 @@ const styles = { }; interface SelectedState { - smoking: string | undefined; - room: string | undefined; + smoking?: string; + room?: string; + mateAge?: string; } interface UserInputProps { - gender: string | undefined; - birthYear: string | undefined; - location: string | undefined; - mbti: string | undefined; - major: string | undefined; - budget: string | undefined; - features: string[] | null; + className?: string; + gender?: string; + birthYear?: string; + location?: string; + mbti?: string; + major?: string; + budget?: string; + features?: string[]; isMySelf: boolean; type: 'myCard' | 'mateCard'; - onVitalChange: ( - optionName: keyof SelectedState, - item: string | number, - ) => void; + onVitalChange: (optionName: keyof SelectedState, item: string) => void; onOptionChange: (option: string) => void; onLocationChange: React.Dispatch>; onMateAgeChange: React.Dispatch>; @@ -51,6 +50,7 @@ interface UserInputProps { } export function UserInputSection({ + className, gender, birthYear, location, @@ -69,7 +69,7 @@ export function UserInputSection({ onBudgetChange, }: UserInputProps) { return ( - + void; onMbtiChange: React.Dispatch>; onMajorChange: React.Dispatch>; @@ -196,7 +196,7 @@ export function OptionSection({ const majorArray = ['공학', '교육', '인문', '사회', '자연', '예체능', '의약']; useEffect(() => { - if (optionFeatures !== null) { + if (optionFeatures != null) { const initialOptions: SelectedOptions = {}; const optionsString = optionFeatures[3].split(':')[1]; const budgetIdx = optionsString.indexOf('['); diff --git a/src/components/card/VitalSection.tsx b/src/components/card/VitalSection.tsx index 905c7ef..6c54ab0 100644 --- a/src/components/card/VitalSection.tsx +++ b/src/components/card/VitalSection.tsx @@ -209,11 +209,15 @@ const CheckItem = styled.div` }}; `; -const years = Array.from({ length: 100 }, (_, index) => 2024 - index); +const years = Array.from( + { length: 100 }, + (_, index) => new Date().getFullYear() - index, +); interface SelectedState { - smoking: string | undefined; - room: string | undefined; + smoking?: string; + room?: string; + mateAge?: string; } export function VitalSection({ @@ -227,14 +231,11 @@ export function VitalSection({ isMySelf, type, }: { - gender: string | undefined; - birthYear: string | undefined; - location: string | undefined; - vitalFeatures: string[] | null; - onFeatureChange: ( - optionName: keyof SelectedState, - item: string | number, - ) => void; + gender?: string; + birthYear?: string; + location?: string; + vitalFeatures?: string[]; + onFeatureChange: (optionName: keyof SelectedState, item: string) => void; onLocationChange: React.Dispatch>; onMateAgeChange: React.Dispatch>; isMySelf: boolean; @@ -252,10 +253,7 @@ export function VitalSection({ }); }, [vitalFeatures]); - function handleOptionClick( - optionName: keyof SelectedState, - item: string | number, - ) { + function handleOptionClick(optionName: keyof SelectedState, item: string) { setSelectedState(prevState => ({ ...prevState, [optionName]: prevState[optionName] === item ? null : item, @@ -284,13 +282,13 @@ export function VitalSection({ const [initialAge, setInitialAge] = useState(0); useEffect(() => { - if (vitalFeatures !== null) + if (vitalFeatures != null) setInitialAge(Number(vitalFeatures?.[2].split(':')[1].slice(1))); }, [vitalFeatures?.[2]]); const [ageValue, setAgeValue] = useState(0); useEffect(() => { - if (initialAge !== undefined) setAgeValue(initialAge); + if (initialAge != null) setAgeValue(initialAge); }, [initialAge]); const handleAgeChange = (e: React.ChangeEvent) => { diff --git a/src/entities/shared-post/shared-post.type.ts b/src/entities/shared-post/shared-post.type.ts index 4f1d020..deaafbf 100644 --- a/src/entities/shared-post/shared-post.type.ts +++ b/src/entities/shared-post/shared-post.type.ts @@ -16,6 +16,7 @@ export interface SharedPostListItem { birthYear: string; gender: string; phoneNumber: string; + profileImageFileName: string; createdAt: Date; createdBy: string; modifiedAt: Date; @@ -24,25 +25,16 @@ export interface SharedPostListItem { roomInfo: { id: number; address: { - city: string; oldAddress: string; roadAddress: string; - detailAddress?: string; - stationName: string; - stationTime: number; - busStopTime: number; - schoolName: string; - schoolTime: number; - convenienceStortTime: number; }; roomType: string; + floorType: string; size: number; numberOfRoom: number; + numberOfBathRoom: number; rentalType: string; - price: number; - managementFee: number; expectedPayment: number; - monthlyFee: number; }; isScrapped: boolean; createdAt: Date; @@ -55,6 +47,8 @@ export interface SharedPost { id: number; title: string; content: string; + roomMateFeatures: string[]; + participants: Array<{ memberId: string; profileImage: string }>; roomImages: Set<{ fileName: string; isThumbnail: boolean; @@ -81,24 +75,27 @@ export interface SharedPost { oldAddress: string; roadAddress: string; detailAddress?: string; - stationName: string; - stationTime: number; - busStopTime: number; - schoolName: string; - schoolTime: number; - convenienceStortTime: number; }; roomType: RoomType; + floorType: string; size: number; numberOfRoom: number; + numberOfBathRoom: number; + hasLivingRoom: boolean; + recruitmentCapacity: number; rentalType: RentalType; - price: number; - managementFee: number; expectedPayment: number; - monthlyFee: number; + extraOption: { + canPark: boolean; + hasAirConditioner: boolean; + hasRefrigerator: boolean; + hasWasher: boolean; + hasTerrace: boolean; + }; }; isScrapped: boolean; scrapCount: number; + viewCount: number; createdAt: Date; createdBy: string; modifiedAt: Date; diff --git a/src/features/shared/shared.api.ts b/src/features/shared/shared.api.ts index 3741de2..40c42ee 100644 --- a/src/features/shared/shared.api.ts +++ b/src/features/shared/shared.api.ts @@ -8,23 +8,66 @@ import { } from './shared.type'; import { + FloorTypeValue, RentalTypeValue, RoomTypeValue, type SuccessBaseDTO, } from '@/shared/types'; -const filterConvertToValues = (filter: GetSharedPostsFilter) => { - const result: Partial> = {}; +const filterConvertToValues = ({ + roomTypes, + rentalTypes, + expectedPaymentRange, + hasLivingRoom, + numberOfRoom, + roomSizeRange, + floorTypes, + canPark, + hasAirConditioner, + hasRefrigerator, + hasWasher, + hasTerrace, +}: GetSharedPostsFilter) => { + const result: { + roomTypes?: number[]; + rentalTypes?: number[]; + expectedPaymentRange?: { start: number; end: number }; + hasLivingRoom?: boolean; + numberOfRoom?: number; + roomSizeRange?: { start: number; end: number }; + floorTypes?: number[]; + canPark?: boolean; + hasAirConditioner?: boolean; + hasRefrigerator?: boolean; + hasWasher?: boolean; + hasTerrace?: boolean; + } = { + expectedPaymentRange, + hasLivingRoom, + numberOfRoom, + roomSizeRange, + canPark, + hasAirConditioner, + hasRefrigerator, + hasWasher, + hasTerrace, + }; + + if (roomTypes != null) { + result.roomTypes = Object.values(roomTypes).map(value => + Number(RoomTypeValue[value]), + ); + } - if (filter.roomType !== undefined) { - result.roomType = Object.values(filter.roomType).map( - value => RoomTypeValue[value], + if (rentalTypes != null) { + result.rentalTypes = Object.values(rentalTypes).map(value => + Number(RentalTypeValue[value]), ); } - if (filter.rentalType !== undefined) { - result.rentalType = Object.values(filter.rentalType).map( - value => RentalTypeValue[value], + if (floorTypes != null) { + result.floorTypes = Object.values(floorTypes).map(value => + Number(FloorTypeValue[value]), ); } @@ -40,11 +83,11 @@ export const getSharedPosts = async ({ const baseURL = '/maru-api/shared/posts/studio'; let query = ''; - if (filter !== undefined) { + if (filter != null) { query += `filter=${JSON.stringify(filterConvertToValues(filter))}`; } - if (search !== undefined) { + if (search != null) { query += `&search=${search}`; } @@ -56,28 +99,8 @@ export const getSharedPosts = async ({ return await axios.get(getURI()); }; -export const createSharedPost = async ({ - imageFilesData, - postData, - transactionData, - roomDetailData, - locationData, -}: CreateSharedPostProps) => { - const formData = new FormData(); - formData.append('imageFilesData', JSON.stringify(imageFilesData)); - formData.append('postData', JSON.stringify(postData)); - formData.append('transactionData', JSON.stringify(transactionData)); - formData.append('roomDetailData', JSON.stringify(roomDetailData)); - formData.append('locationData', JSON.stringify(locationData)); - - return await axios.post( - `/maru-api/shared/posts/studio`, - formData, - { - headers: { 'Content-Type': 'multipart/form-data' }, - }, - ); -}; +export const createSharedPost = async (postData: CreateSharedPostProps) => + await axios.post(`/maru-api/shared/posts/studio`, postData); export const getSharedPost = async (postId: number) => await axios.get(`/maru-api/shared/posts/studio/${postId}`); diff --git a/src/features/shared/shared.dto.ts b/src/features/shared/shared.dto.ts index 663f7f6..65997e9 100644 --- a/src/features/shared/shared.dto.ts +++ b/src/features/shared/shared.dto.ts @@ -5,7 +5,6 @@ import { import { type SuccessBaseDTO } from '@/shared/types'; export interface GetSharedPostsDTO extends SuccessBaseDTO { - message: string; data: { content: SharedPostListItem[]; pageable: { diff --git a/src/features/shared/shared.hook.ts b/src/features/shared/shared.hook.ts index 1efb729..36ffe31 100644 --- a/src/features/shared/shared.hook.ts +++ b/src/features/shared/shared.hook.ts @@ -1,6 +1,6 @@ import { useMutation, useQuery } from '@tanstack/react-query'; import { type AxiosResponse } from 'axios'; -import { useCallback, useMemo, useState } from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; import { createSharedPost, @@ -10,10 +10,15 @@ import { scrapPost, } from './shared.api'; import { + type ImageFile, type CreateSharedPostProps, type GetSharedPostsProps, + type SelectedExtraOptions, + type SelectedOptions, } from './shared.type'; +import { useAuthValue } from '@/features/auth'; +import { type NaverAddress } from '@/features/geocoding'; import { type FailureDTO, type SuccessBaseDTO } from '@/shared/types'; export const usePaging = ({ @@ -83,6 +88,247 @@ 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({}); + + const handleExtraOptionClick = useCallback((option: string) => { + setSelectedExtraOptions(prevSelectedOptions => ({ + ...prevSelectedOptions, + [option]: !prevSelectedOptions[option], + })); + }, []); + + 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, + ], + ); +}; + +export const usePostMateCardInputSection = () => { + const [gender, setGender] = useState(undefined); + const [birthYear, setBirthYear] = useState(undefined); + const [location, setLocation] = useState(undefined); + const [mbti, setMbti] = useState(undefined); + const [major, setMajor] = useState(undefined); + const [budget, setBudget] = useState(undefined); + + const [features, setFeatures] = useState<{ + smoking?: string; + room?: string; + mateAge?: string; + options: Set; + }>({ options: new Set() }); + + const handleEssentialFeatureChange = useCallback( + (key: 'smoking' | 'room' | 'mateAge', value: string) => { + setFeatures(prev => { + if (prev[key] === value) { + const newFeatures = { ...prev }; + newFeatures[key] = undefined; + return newFeatures; + } + return { ...prev, [key]: value }; + }); + }, + [], + ); + + const handleOptionalFeatureChange = useCallback((option: string) => { + setFeatures(prev => { + const { options } = prev; + const newOptions = new Set(options); + + if (options.has(option)) newOptions.delete(option); + else newOptions.add(option); + + return { ...prev, options: newOptions }; + }); + }, []); + + const derivedFeatures = useMemo(() => { + const options: string[] = []; + features.options.forEach(option => options.push(option)); + + let mateAge: number | null = null; + if (features?.mateAge != null) { + if (features.mateAge === '동갑') { + mateAge = 0; + } else if (features.mateAge === '상관없어요') { + mateAge = null; + } else { + mateAge = Number(features.mateAge.slice(1)); + } + } else { + mateAge = null; + } + + return { + smoking: features?.smoking ?? '상관없어요', + roomSharingOption: features?.room ?? '상관없어요', + mateAge: mateAge ?? null, + options: JSON.stringify(options), + }; + }, [features]); + + const auth = useAuthValue(); + useEffect(() => { + if (auth?.user != null) { + setGender(auth.user.gender); + } + }, [auth?.user]); + + const isMateCardCreatable = useMemo( + () => + gender != null && birthYear != null && location != null && budget != null, + [gender, birthYear, location, budget], + ); + + return useMemo( + () => ({ + gender, + setGender, + birthYear, + setBirthYear, + location, + setLocation, + mbti, + setMbti, + major, + setMajor, + budget, + setBudget, + derivedFeatures, + handleEssentialFeatureChange, + handleOptionalFeatureChange, + isMateCardCreatable, + }), + [ + gender, + setGender, + birthYear, + setBirthYear, + location, + setLocation, + mbti, + setMbti, + major, + setMajor, + budget, + setBudget, + derivedFeatures, + handleEssentialFeatureChange, + handleOptionalFeatureChange, + isMateCardCreatable, + ], + ); +}; + export const useCreateSharedPost = () => useMutation, FailureDTO, CreateSharedPostProps>( { diff --git a/src/features/shared/shared.type.ts b/src/features/shared/shared.type.ts index 870077f..5c603de 100644 --- a/src/features/shared/shared.type.ts +++ b/src/features/shared/shared.type.ts @@ -1,8 +1,18 @@ -import { type RentalType, type RoomType } from '@/shared/types'; +import { type FloorType, type RentalType, type RoomType } from '@/shared/types'; export interface GetSharedPostsFilter { - roomType?: RoomType[]; - rentalType?: RentalType[]; + roomTypes?: RoomType[]; + rentalTypes?: RentalType[]; + expectedPaymentRange?: { start: number; end: number }; + hasLivingRoom?: boolean; + numberOfRoom?: number; + roomSizeRange?: { start: number; end: number }; + floorTypes?: FloorType[]; + canPark?: boolean; + hasAirConditioner?: boolean; + hasRefrigerator?: boolean; + hasWasher?: boolean; + hasTerrace?: boolean; } export interface GetSharedPostsProps { @@ -11,6 +21,23 @@ export interface GetSharedPostsProps { page: number; } +export interface SelectedOptions { + budget?: string; + roomType?: string; + livingRoom?: string; + roomCount?: string; + restRoomCount?: string; + floorType?: string; +} + +export type SelectedExtraOptions = Record; + +export interface ImageFile { + url: string; + file: File; + extension: string; +} + export interface CreateSharedPostProps { imageFilesData: Array<{ fileName: string; @@ -22,26 +49,37 @@ export interface CreateSharedPostProps { content: string; }; transactionData: { - rentalType: string; - price: number; - monthlyFee: number; - managementFee: number; + rentalType: number; + expectedPayment: number; }; roomDetailData: { - roomType: string; + roomType: number; + floorType: number; size: number; numberOfRoom: number; + numberOfBathRoom: number; + hasLivingRoom: boolean; recruitmentCapacity: number; + extraOption: { + canPark: boolean; + hasAirConditioner: boolean; + hasRefrigerator: boolean; + hasWasher: boolean; + hasTerrace: boolean; + }; }; locationData: { - city: string; oldAddress: string; roadAddress: string; - stationName: string; - stationTime: number; - busStopTime: number; - schoolName: string; - schoolTime: number; - convenienceStoreTime: number; }; + roomMateCardData: { + location: string; + features: { + smoking: string; + roomSharingOption: string; + mateAge: number | null; // 0 ~ 10: +- 범위 값, null: 상관 없어요. + options: string; + }; + }; + participationMemberIds: string[]; } diff --git a/src/shared/types/floor-type.ts b/src/shared/types/floor-type.ts new file mode 100644 index 0000000..f0598d5 --- /dev/null +++ b/src/shared/types/floor-type.ts @@ -0,0 +1,7 @@ +export type FloorType = 'GROUND' | 'SEMI_BASEMENT' | 'PENTHOUSE'; + +export const FloorTypeValue: Record = { + GROUND: 0, + SEMI_BASEMENT: 1, + PENTHOUSE: 2, +}; diff --git a/src/shared/types/index.ts b/src/shared/types/index.ts index e932229..38263f7 100644 --- a/src/shared/types/index.ts +++ b/src/shared/types/index.ts @@ -1,3 +1,4 @@ export * from './dto'; +export * from './floor-type'; export * from './rental-type'; export * from './room-type'; diff --git a/src/shared/types/room-type.ts b/src/shared/types/room-type.ts index 833bbe7..cde668b 100644 --- a/src/shared/types/room-type.ts +++ b/src/shared/types/room-type.ts @@ -1,18 +1,8 @@ -export type RoomType = - | 'VILLA_1' - | 'VILLA_2' - | 'VILLA_3' - | 'OFFICE_TEL_1' - | 'OFFICE_TEL_2' - | 'OFFICE_TEL_3' - | 'APT'; +export type RoomType = 'ONE_ROOM' | 'TWO_ROOM_VILLA' | 'APT' | 'OFFICE_TEL'; export const RoomTypeValue: Record = { - VILLA_1: 0, - VILLA_2: 1, - VILLA_3: 2, - OFFICE_TEL_1: 3, - OFFICE_TEL_2: 4, - OFFICE_TEL_3: 5, - APT: 6, + ONE_ROOM: 0, + TWO_ROOM_VILLA: 1, + APT: 2, + OFFICE_TEL: 3, };