diff --git a/package-lock.json b/package-lock.json index b17045c..da7ac1b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "p-travel-log", - "version": "1.0.1", + "version": "1.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "p-travel-log", - "version": "1.0.1", + "version": "1.1.0", "dependencies": { "@emotion/react": "^11.13.3", "@emotion/styled": "^11.13.0", diff --git a/package.json b/package.json index 2486819..31a1046 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "p-travel-log", - "version": "1.0.1", + "version": "1.1.0", "private": true, "scripts": { "dev": "next dev", diff --git a/src/__test__/NavigationBar.test.tsx b/src/__test__/NavigationBar.test.tsx index 3b4915d..d52fa04 100644 --- a/src/__test__/NavigationBar.test.tsx +++ b/src/__test__/NavigationBar.test.tsx @@ -40,16 +40,16 @@ describe('내비게이션 바 스냅샷 테스트', () => { mockUsePathname.mockReturnValue('/test'); const { getByText } = render(); - expect(getByText('여행하기')).toBeInTheDocument(); + expect(getByText('놀러가기')).toBeInTheDocument(); expect(getByText('기록하기')).toBeInTheDocument(); }); - test('pathname이 / 경우 여행하기 메뉴가 활성화 되어야 합니다.', () => { + test('pathname이 / 경우 놀러가기 메뉴가 활성화 되어야 합니다.', () => { mockUsePathname.mockReturnValue('/'); render(); expect(screen.getByAltText('travel-active-menu')).toBeInTheDocument(); - expect(screen.getByText('여행하기')).toHaveStyle('color: #605EFF'); + expect(screen.getByText('놀러가기')).toHaveStyle('color: #605EFF'); }); test('pathname이 /record인 경우 기록하기 메뉴가 활성화 되어야 합니다.', () => { @@ -60,11 +60,11 @@ describe('내비게이션 바 스냅샷 테스트', () => { expect(screen.getByText('기록하기')).toHaveStyle('color: #605EFF'); }); - test('여행하기 메뉴에 클릭 이벤트가 발생한 경우 router가 / 로 replace 되어야 합니다.', () => { + test('놀러가기 메뉴에 클릭 이벤트가 발생한 경우 router가 / 로 replace 되어야 합니다.', () => { mockUsePathname.mockReturnValue('/'); const { getByText } = render(); - fireEvent.click(getByText('여행하기')); + fireEvent.click(getByText('놀러가기')); expect(mockRouter.replace).toHaveBeenCalledWith('/'); }); diff --git a/src/app/pages/travel-page.tsx b/src/app/pages/travel-page.tsx index 02752fc..a23c2e5 100644 --- a/src/app/pages/travel-page.tsx +++ b/src/app/pages/travel-page.tsx @@ -34,11 +34,15 @@ export function TravelPage() {
어디가실 계획인가요? - + { handleButtonClick('/travel/auto'); }} diff --git a/src/app/pages/travel-traveler-page.tsx b/src/app/pages/travel-traveler-page.tsx index dfbe8f2..ef42c3c 100644 --- a/src/app/pages/travel-traveler-page.tsx +++ b/src/app/pages/travel-traveler-page.tsx @@ -34,12 +34,12 @@ const transitionMap: { [key in UIState]: { NEXT?: UIState; PREV?: UIState } } = NEXT: 'traveler-add-days', }, 'traveler-add-days': { - NEXT: 'traveler-activity-selection', + NEXT: 'traveler-travel-schedule-confirm', PREV: 'traveler-schedule-selection', }, 'traveler-activity-selection': { NEXT: 'traveler-activity-recommendation', - PREV: 'traveler-add-days', + PREV: 'traveler-travel-schedule-confirm', }, 'traveler-activity-recommendation': { NEXT: 'traveler-travel-schedule-confirm', @@ -47,7 +47,7 @@ const transitionMap: { [key in UIState]: { NEXT?: UIState; PREV?: UIState } } = }, 'traveler-travel-schedule-confirm': { NEXT: 'traveler-travel-schedule-arrange', - PREV: 'traveler-activity-selection', + PREV: 'traveler-add-days', }, 'traveler-travel-schedule-arrange': { PREV: 'traveler-travel-schedule-confirm', @@ -61,7 +61,8 @@ const uiReducer = (state: UIState, action: UIAction): UIState => { }; export function TravelerPage() { - const { tourInfo, isAllTravelSchedulesFilled, setLocation } = useTripStore(); + const { tourInfo, isAllTravelSchedulesFilled, setLocation, setSelectedDay } = + useTripStore(); useEffect(() => { const savedContent = sessionStorage.getItem('searchContent'); @@ -139,15 +140,21 @@ export function TravelerPage() { dispatch({ type: 'NEXT' })} onPrevPage={() => dispatch({ type: 'PREV' })} + onRecommendPage={(day) => { + setSelectedDay(day); + dispatch({ + type: 'NEXT', + payload: { nextState: 'traveler-activity-selection' }, + }); + }} /> ); case 'traveler-travel-schedule-arrange': return ( { - router.replace('/'); + router.replace('/record'); }} - onPrevPage={() => dispatch({ type: 'PREV' })} /> ); default: diff --git a/src/components/NavigationBar.tsx b/src/components/NavigationBar.tsx index 23fbcb5..a252ea4 100644 --- a/src/components/NavigationBar.tsx +++ b/src/components/NavigationBar.tsx @@ -49,12 +49,12 @@ export function NavigationBar() { {pathName === '/' || pathName.startsWith('/travel') ? ( - 여행하기 + 놀러가기 ) : ( - 여행하기 + 놀러가기 )} diff --git a/src/components/SearchBox.tsx b/src/components/SearchBox.tsx index 1964442..1c7a03d 100644 --- a/src/components/SearchBox.tsx +++ b/src/components/SearchBox.tsx @@ -9,10 +9,14 @@ export function SearchBox({ onClick, setContent, dropBoxType, + placeholder, + className, }: { onClick?: () => void; setContent?: (value: string) => void; dropBoxType?: 'travelType' | 'regionType'; + placeholder?: string; + className?: string; }) { const [value, setValue] = useState(null); const [dropBoxVisible, setDropBoxVisible] = useState(false); @@ -27,9 +31,11 @@ export function SearchBox({ onClick={() => { if (dropBoxType !== undefined) setDropBoxVisible(true); }} + className={className} > (null); + const { createToast } = useToast(); const { data: tripSchedule, status } = useTripSchedule(selectedTravel); const [groupedData, setGroupedData] = useState< @@ -52,10 +54,22 @@ export function TravelLog({ selectedTravel }: { selectedTravel: number }) { ...prev, [id]: review, })); - updateReview({ - tourActivityId: id, - history: review, - }); + updateReview( + { + tourActivityId: id, + history: review, + }, + { + onSuccess: () => { + createToast( + review != null ? 'success' : 'error', + review != null + ? '한 줄 평이 저장되었습니다!' + : '한 줄 평이 삭제되었습니다.', + ); + }, + }, + ); }; const handleSetRecommend = (id: number, recommend: boolean | null) => { @@ -63,10 +77,15 @@ export function TravelLog({ selectedTravel }: { selectedTravel: number }) { ...prev, [id]: recommend, })); - updateRecommend({ - tourActivityId: id, - recommend, - }); + updateRecommend( + { + tourActivityId: id, + recommend, + }, + { + onSuccess: () => createToast('success', '저장되었습니다!'), + }, + ); }; useEffect(() => { @@ -120,6 +139,11 @@ export function TravelLog({ selectedTravel }: { selectedTravel: number }) { onClick={() => { setSelectedDay(dayNumber); setCurrentPage(0); + createToast( + 'info', + '여행에 대한 한 줄 평과 좋아요를 남겨보세요!', + 5000, + ); }} > 기록하기 @@ -130,7 +154,9 @@ export function TravelLog({ selectedTravel }: { selectedTravel: number }) {
  • -

    {activity.spotName}

    + + {activity.spotName} +

    {translateDayTime(activity.dayTime)} @@ -221,6 +247,10 @@ interface ActiveProp { $active: boolean; } +interface History { + $isHistory?: string | null; +} + const styles = { wrapper: styled.div` flex: 1 0 0; @@ -322,21 +352,6 @@ const styles = { align-items: center; } - .name { - color: #7d7d7d; - font-family: 'Noto Sans KR'; - font-size: 0.9375rem; - font-style: normal; - font-weight: 700; - line-height: normal; - letter-spacing: -0.01875rem; - - max-width: 50%; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - } - .dotted-line { flex-grow: 1; border-bottom: 1px dashed #d3d3d3; @@ -354,6 +369,24 @@ const styles = { } `, + name: styled.p` + color: #7d7d7d; + font-family: 'Noto Sans KR'; + font-size: 0.9375rem; + font-style: normal; + font-weight: 700; + line-height: normal; + letter-spacing: -0.01875rem; + + background-color: ${(props) => + props.$isHistory != null ? '#f5fca6' : 'transparent'}; + + max-width: 50%; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + `, + pin: styled.img` width: 0.3125rem; height: 0.875rem; diff --git a/src/components/profile/EditPasswordSection.tsx b/src/components/profile/EditPasswordSection.tsx index 5b3a899..ae1484c 100644 --- a/src/components/profile/EditPasswordSection.tsx +++ b/src/components/profile/EditPasswordSection.tsx @@ -3,13 +3,13 @@ import styled from '@emotion/styled'; import { useState } from 'react'; -import { useChangePassword } from '@/features/member'; +import { putPassword } from '@/features/member/member.api'; +import { useToast } from '@/features/toast'; export function EditPasswordSection({ onClick }: { onClick: () => void }) { const [oldPassword, setOldPassword] = useState(''); const [newPassword, setNewPassword] = useState(''); - - const { mutate: changePWD } = useChangePassword(); + const { createToast } = useToast(); return ( @@ -37,8 +37,14 @@ export function EditPasswordSection({ onClick }: { onClick: () => void }) { { - changePWD({ oldPassword, newPassword }); - onClick(); + putPassword(oldPassword, newPassword) + .then(() => { + createToast('success', '비밀번호가 변경되었습니다.'); + }) + .catch(() => { + createToast('error', '현재 비밀번호가 일치하지 않습니다.'); + }) + .finally(onClick); }} > 확인 diff --git a/src/components/profile/EditProfileSection.tsx b/src/components/profile/EditProfileSection.tsx index 25ca97c..5b6c617 100644 --- a/src/components/profile/EditProfileSection.tsx +++ b/src/components/profile/EditProfileSection.tsx @@ -4,6 +4,7 @@ import styled from '@emotion/styled'; import { useState } from 'react'; import { useChangeUserName, type User } from '@/features/member'; +import { useToast } from '@/features/toast'; export function EditProfileSection({ user, @@ -49,6 +50,7 @@ function ProfileInfo({ function ListItem({ menu, content }: { menu: string; content?: string }) { const [value, setValue] = useState(); const { mutate: changeUserName } = useChangeUserName(); + const { createToast } = useToast(); return (

  • {menu}

    @@ -62,7 +64,13 @@ function ListItem({ menu, content }: { menu: string; content?: string }) { {menu === '닉네임' && ( { - if (value) changeUserName(value); + if (value) + changeUserName(value, { + onSuccess: () => + createToast('success', '변경이 완료되었습니다.'), + onError: () => + createToast('error', '변경하려는 이름이 너무 깁니다.'), + }); }} > 변경하기 @@ -141,6 +149,7 @@ const styles = { `, changeButton: styled.button` + min-width: 3.33rem; padding: 0.15rem 0.38rem; border-radius: 0.25rem; border: 0.4px solid #c9c9c9; @@ -188,7 +197,7 @@ const styles = { input { border: none; - width: 50%; + flex: 1; height: 100%; color: #b5b5b5; text-align: right; diff --git a/src/components/travel/ReviewCard.tsx b/src/components/travel/ReviewCard.tsx index 5472cb2..a61507f 100644 --- a/src/components/travel/ReviewCard.tsx +++ b/src/components/travel/ReviewCard.tsx @@ -85,7 +85,6 @@ function ReviewInput({ {review ? ( <> void; onPrevPage: () => void; }) { - const { isLoading, tourInfo, recommendContent, fillActivities } = + const { isLoading, tourInfo, recommendContent, fillActivities, selectedDay } = useTripStore(); const [isDetailVisible, setIsDetailVisible] = useState(false); @@ -63,6 +64,17 @@ export function TravelerActivityRecommendation({ setIsDetailVisible(true); }; + const { createToast } = useToast(); + + useEffect(() => { + if (!isLoading) + createToast( + 'info', + '기존 계획에 특정 시간대가 추가되어있다면, 추천에서 해당 시간대를 추가하더라도 추가되지 않습니다.', + 5000, + ); + }, [isLoading]); + return ( {!isDetailVisible && !isLoading && ( @@ -88,7 +100,10 @@ export function TravelerActivityRecommendation({ color='#FF75C8' text='여행 완성' onClick={() => { + if (!selectedDay) return; + fillActivities( + selectedDay, selectedPlaces.map((place) => ({ dayTime: convertTimeString(place.time ?? '오전'), orderIndex: 0, diff --git a/src/components/travel/traveler/TravelerActivitySelection.tsx b/src/components/travel/traveler/TravelerActivitySelection.tsx index e530c8c..65450bd 100644 --- a/src/components/travel/traveler/TravelerActivitySelection.tsx +++ b/src/components/travel/traveler/TravelerActivitySelection.tsx @@ -64,7 +64,7 @@ export function TravelerActivitySelection({ 나머지 일정도 추천해드릴게요. 어떤 활동을 하고 싶으세요? - { setRecommendContent(value); @@ -100,37 +100,6 @@ export function TravelerActivitySelection({ onNextPage(); }} /> - { - setIsLoading(true); - postDayTrip({ - tourDate: new Date().toISOString(), - sigunguCode: String( - types.regionType.find( - (region) => region.type === tourInfo.locationName, - )?.id ?? '1', - ), - contentTypeId: String( - types.travelType.find( - (region) => region.type === recommendContent, - )?.id ?? '1', - ), - dayTimes: ['MORNING', 'AFTERNOON', 'EVENING', 'NIGHT'], - }) - .then((res) => { - setRecommendedItems(res.data); - setIsLoading(false); - }) - .catch(() => { - createToast('error', '오류가 발생했습니다. 다시 시도해주세요.'); - onPrevPage(); - }); - - onNextPage(); - }} - /> ); } @@ -185,4 +154,8 @@ const styles = { width: 1rem; height: 1rem; `, + + SearchBox: styled(SearchBox)` + margin-bottom: auto; + `, }; diff --git a/src/components/travel/traveler/TravelerAddDays.tsx b/src/components/travel/traveler/TravelerAddDays.tsx index 45a7556..0520bad 100644 --- a/src/components/travel/traveler/TravelerAddDays.tsx +++ b/src/components/travel/traveler/TravelerAddDays.tsx @@ -7,10 +7,21 @@ import { CustomButton } from '@/components'; import { TravelerLocationConfirm } from '@/components/travel/traveler/TravelerLocationConfirm'; import { TravelerLocationSearch } from '@/components/travel/traveler/TravelerLocationSearch'; import { useToast } from '@/features/toast'; -import { getTourSpots, type GetTourSpotsDTO } from '@/features/tour-spot'; -import { TIME_STRING } from '@/features/trip'; +import { + getTourSpotContents, + getTourSpots, + type GetTourSpotsDTO, +} from '@/features/tour-spot'; +import { postDayTrip, TIME_STRING } from '@/features/trip'; import { useTripStore } from '@/features/trip/trip.slice'; +interface Location { + contentId: string; + contentTypeId: string; + title: string; + imageUrl: string; +} + export function TravelerAddDays({ onPrevPage, onNextPage, @@ -33,10 +44,13 @@ export function TravelerAddDays({ removeTour, addActivity, removeActivity, + setIsLoading, } = useTripStore(); const { createToast } = useToast(); + const [locations, setLocations] = useState([]); + return ( <> {state.ui === 'main' && ( @@ -112,18 +126,45 @@ export function TravelerAddDays({ )} {state.ui === 'search' && ( { if (!state.location) return; - getTourSpots(state.location, tourInfo.sigunguCode ?? '').then( - (res) => { - setState((prev) => ({ - ...prev, - ui: 'confirm', - tourSpotDto: res, - })); - }, - ); + setLocations([]); + setIsLoading(true); + getTourSpots(state.location, tourInfo.sigunguCode ?? '') + .then((res) => { + const { contentId, contentTypeId, sigunguCode } = res.data; + + getTourSpotContents(contentId, contentTypeId) + .then((content) => ({ data: content.data })) + .then((contentData) => { + postDayTrip({ + contentTypeId, + dayTimes: ['MORNING', 'AFTERNOON', 'EVENING', 'NIGHT'], + sigunguCode, + tourDate: new Date().toISOString(), + }) + .then((recommend) => { + setLocations((prev) => [ + ...prev, + ...recommend.data.filter( + (v) => v.contentId !== contentData.data.contentId, + ), + { + contentId: contentData.data.contentId, + contentTypeId: contentData.data.contentTypeId, + title: contentData.data.title, + imageUrl: contentData.data.firstImage, + }, + ]); + }) + .finally(() => setIsLoading(false)); + }); + }) + .catch(() => { + setIsLoading(false); + }); }} onContentChange={(value) => setState((prev) => ({ ...prev, location: value })) @@ -134,6 +175,21 @@ export function TravelerAddDays({ ui: 'main', })); }} + onItemClick={(location) => { + setState((prev) => ({ + ...prev, + ui: 'confirm', + tourSpotDto: { + status: 'success', + data: { + title: location.title, + contentId: location.contentId, + contentTypeId: location.contentTypeId, + sigunguCode: tourInfo.sigunguCode ?? '1', + }, + }, + })); + }} /> )} {state.ui === 'confirm' && @@ -180,6 +236,7 @@ export function TravelerAddDays({ }); setState(() => ({ ui: 'main' })); + setLocations([]); }} onPrevPage={() => { setState((prev) => ({ ...prev, ui: 'search' })); diff --git a/src/components/travel/traveler/TravelerLocationSearch.tsx b/src/components/travel/traveler/TravelerLocationSearch.tsx index e902d4f..9d9bf47 100644 --- a/src/components/travel/traveler/TravelerLocationSearch.tsx +++ b/src/components/travel/traveler/TravelerLocationSearch.tsx @@ -3,16 +3,33 @@ import styled from '@emotion/styled'; import { SearchBox } from '@/components'; +import { Loading } from '@/components/travel'; +import { useTripStore } from '@/features/trip/trip.slice'; + +interface Location { + contentId: string; + contentTypeId: string; + title: string; + imageUrl: string; +} export function TravelerLocationSearch({ + locations, onClick, onContentChange, onPrevPage, + onItemClick, }: { + locations: Location[]; onClick: () => void; onContentChange: (value: string) => void; onPrevPage: () => void; + onItemClick: (lcoation: Location) => void; }) { + const { isLoading } = useTripStore(); + + if (isLoading) return ; + return ( @@ -23,11 +40,43 @@ export function TravelerLocationSearch({ />

    미리 계획한 장소를 입력하세요.

    - + + {locations.length > 0 ? ( + + {locations.map((location) => ( + onItemClick(location)} + /> + ))} + + ) : ( + 검색을 해주세요! + )}
    ); } +function LocationItem({ + location: { title, imageUrl }, + onClick, +}: { + location: Location; + onClick: () => void; +}) { + return ( + + {imageUrl} +

    {title}

    +
    + ); +} + const styles = { container: styled.div` flex-grow: 1; @@ -49,14 +98,46 @@ const styles = { header: styled.div` display: flex; - position: fixed; align-items: center; gap: 0.5rem; - transform: translate(-110%, 45%); `, prevButton: styled.img` width: 1rem; height: 1rem; `, + + results: styled.div` + display: flex; + flex-wrap: wrap; + justify-content: space-between; + row-gap: 2rem; + `, + + locationItem: styled.div` + width: calc(50% - 1rem); + height: 100px; + + img { + width: 100%; + height: 90%; + + background: #fff; + border-radius: 8px; + box-shadow: 0px 1px 4px 0px #6e80913d; + + object-fit: cover; + object-position: center; + } + `, + + empty: styled.p` + font-family: Noto Sans KR; + font-size: 1rem; + font-weight: 700; + line-height: 33.3px; + text-align: center; + + color: #505050; + `, }; diff --git a/src/components/travel/traveler/TravelerScheduleConfirm.tsx b/src/components/travel/traveler/TravelerScheduleConfirm.tsx index 7c98baa..6824c31 100644 --- a/src/components/travel/traveler/TravelerScheduleConfirm.tsx +++ b/src/components/travel/traveler/TravelerScheduleConfirm.tsx @@ -3,11 +3,7 @@ import styled from '@emotion/styled'; import { useState } from 'react'; -import { TravelerLocationConfirm } from '@/components/travel/traveler/TravelerLocationConfirm'; -import { TravelerLocationSearch } from '@/components/travel/traveler/TravelerLocationSearch'; import { useToast } from '@/features/toast'; -import type { GetTourSpotsDTO } from '@/features/tour-spot'; -import { getTourSpots } from '@/features/tour-spot'; import { getTripSchedule, postTripSchedule, @@ -19,177 +15,95 @@ import { useTripStore } from '@/features/trip/trip.slice'; export function TravelerScheduleConfirm({ onNextPage, onPrevPage, + onRecommendPage, }: { onNextPage: () => void; onPrevPage: () => void; + onRecommendPage: (day: number) => void; }) { - const [state, setState] = useState<{ - ui: 'main' | 'search' | 'confirm'; - day?: number; - location?: string; - time?: 'MORNING' | 'AFTERNOON' | 'EVENING' | 'NIGHT'; - tourSpotDto?: GetTourSpotsDTO; - }>({ ui: 'main' }); + const { tourInfo, activities, setIsLoading, load } = useTripStore(); - const { tourInfo, activities, setIsLoading, addActivity, load } = - useTripStore(); + const [enabled, setEnabled] = useState(true); const { createToast } = useToast(); return ( - <> - {state.ui === 'main' && ( - - - - - {tourInfo.locationName} - {activities.map((acts, index) => ( - { - setState((prev) => ({ - ...prev, - ui: 'search', - day: index + 1, - })); - }} - /> - ))} - { - if ( - !tourInfo.name || - !tourInfo.locationName || - !tourInfo.startTime || - !tourInfo.endTime - ) - return; - - if (activities.some((v) => v.length === 0)) { - createToast('info', '장소를 한 개 이상 선택해주세요'); - return; - } - - postTripSchedule({ - tourLogData: { - name: tourInfo.name, - locationName: tourInfo.locationName, - startTime: tourInfo.startTime.toISOString().slice(0, -5), - endTime: tourInfo.endTime.toISOString().slice(0, -5), - }, - tourActivityDataList: activities.flat().map((act) => ({ - spotName: act.spotName, - dayNumber: act.dayNumber, - dayTime: act.dayTime, - orderIndex: 0, - tourSpotData: { - contentId: act.tourSpotDto.id, - contentTypeId: act.tourSpotDto.typeId, - }, - })), - }).then((res) => { - const { data: logId } = res; - - setIsLoading(true); - getTripSchedule(logId) - .then((dto) => { - load(dto); - onNextPage(); - }) - .catch(() => { - createToast('error', '다시 시도해주세요.'); - }) - .finally(() => { - setIsLoading(false); - }); - }); - }} - > - 여행 완성 - - - )}{' '} - {state.ui === 'search' && ( - { - if (!state.location) return; - - getTourSpots(state.location, tourInfo.sigunguCode ?? '').then( - (res) => { - setState((prev) => ({ - ...prev, - ui: 'confirm', - tourSpotDto: res, - })); - }, - ); - }} - onContentChange={(value) => - setState((prev) => ({ ...prev, location: value })) - } - onPrevPage={() => { - setState((prev) => ({ ...prev, ui: 'main' })); + + + + + {tourInfo.locationName} + {activities.map((acts, index) => ( + { + onRecommendPage(index + 1); }} /> - )} - {state.ui === 'confirm' && - state.tourSpotDto && - state.day && - state.location && ( - { - if ( - state.day && - !activities[state.day - 1]?.find( - (activity) => activity.dayTime === time, - ) - ) { - setState((prev) => ({ ...prev, time })); - } else { - createToast('error', '이미 선택된 시간대입니다!'); - } - }} - onConfirm={() => { - if ( - !state.day || - !state.time || - !state.tourSpotDto || - !tourInfo.startTime || - !tourInfo.endTime - ) - return; - - addActivity(state.day, { - dayNumber: state.day, - dayTime: state.time, - spotName: state.tourSpotDto.data.title, - tourSpotDto: { - id: state.tourSpotDto.data.contentId, - typeId: state.tourSpotDto.data.contentTypeId, - title: state.tourSpotDto.data.title, - sigunguCode: state.tourSpotDto.data.sigunguCode, - }, - orderIndex: 0, - }); - - setState(() => ({ ui: 'main' })); - }} - onPrevPage={() => { - setState((prev) => ({ ...prev, ui: 'search' })); - }} - /> - )} - + ))} + { + if (!enabled) return; + + if ( + !tourInfo.name || + !tourInfo.locationName || + !tourInfo.startTime || + !tourInfo.endTime + ) + return; + + if (activities.some((v) => v.length === 0)) { + createToast('info', '장소를 한 개 이상 선택해주세요'); + return; + } + + postTripSchedule({ + tourLogData: { + name: tourInfo.name, + locationName: tourInfo.locationName, + startTime: tourInfo.startTime.toISOString().slice(0, -5), + endTime: tourInfo.endTime.toISOString().slice(0, -5), + }, + tourActivityDataList: activities.flat().map((act) => ({ + spotName: act.spotName, + dayNumber: act.dayNumber, + dayTime: act.dayTime, + orderIndex: 0, + tourSpotData: { + contentId: act.tourSpotDto.id, + contentTypeId: act.tourSpotDto.typeId, + }, + })), + }) + .then((res) => { + const { data: logId } = res; + + setIsLoading(true); + getTripSchedule(logId) + .then((dto) => { + load(dto); + onNextPage(); + }) + .catch(() => { + createToast('error', '다시 시도해주세요.'); + }) + .finally(() => { + setIsLoading(false); + }); + }) + .finally(() => setEnabled(false)); + }} + > + 여행 완성 + + ); } diff --git a/src/components/travel/traveler/TravelerTravelArrange.tsx b/src/components/travel/traveler/TravelerTravelArrange.tsx index 408e21f..a779163 100644 --- a/src/components/travel/traveler/TravelerTravelArrange.tsx +++ b/src/components/travel/traveler/TravelerTravelArrange.tsx @@ -9,10 +9,8 @@ import { useTripStore } from '@/features/trip/trip.slice'; export function TravelerTravelArrange({ onNextPage, - onPrevPage, }: { onNextPage: () => void; - onPrevPage: () => void; }) { const { tourInfo, activities, isLoading } = useTripStore(); @@ -20,13 +18,6 @@ export function TravelerTravelArrange({ return ( - - - {tourInfo.locationName} {activities.map((acts, index) => ( diff --git a/src/features/member/member.hook.ts b/src/features/member/member.hook.ts index 3ff6f60..9261043 100644 --- a/src/features/member/member.hook.ts +++ b/src/features/member/member.hook.ts @@ -71,7 +71,7 @@ export const useChangePassword = () => oldPassword: string; newPassword: string; }) => putPassword(oldPassword, newPassword), - onSuccess: (data) => data.data, + onSuccess: (data) => data, }); export const useAuth = () => { diff --git a/src/features/toast/toast.hook.ts b/src/features/toast/toast.hook.ts index c699093..3075d42 100644 --- a/src/features/toast/toast.hook.ts +++ b/src/features/toast/toast.hook.ts @@ -6,9 +6,13 @@ import { type ToastType } from './toast.type'; export function useToast() { const { addToast } = useToastStore(); - const createToast = (type: ToastType, message: string) => { + const createToast = ( + type: ToastType, + message: string, + duration: number = 2000, + ) => { const id = uuidv4(); - addToast({ id, type, message }); + addToast({ id, type, message, duration }); }; return { createToast }; diff --git a/src/features/toast/toast.type.ts b/src/features/toast/toast.type.ts index f1dfe47..7c48e1c 100644 --- a/src/features/toast/toast.type.ts +++ b/src/features/toast/toast.type.ts @@ -4,6 +4,7 @@ export interface Toast { id: string; message: string; type: ToastType; + duration?: number; } export interface ToastState { diff --git a/src/features/trip/trip.slice.ts b/src/features/trip/trip.slice.ts index b4b3818..4fad5b3 100644 --- a/src/features/trip/trip.slice.ts +++ b/src/features/trip/trip.slice.ts @@ -34,6 +34,7 @@ export interface TripState { tourInfo: TourInfo; activities: Array>; + selectedDay?: number; isLoading: boolean; recommendContent?: string; recommendedItems: TripItem[]; @@ -50,8 +51,9 @@ export interface TripAction { removeTour: (day: number) => void; addActivity: (day: number, activity: Activity) => void; removeActivity: (day: number, activity: Activity) => void; - fillActivities: (items: Activity[]) => void; + fillActivities: (day: number, items: Activity[]) => void; load: (dto: GetTripScheduleResponseDTO) => void; + setSelectedDay: (day: number) => void; } const initialState: TripState = { @@ -118,13 +120,15 @@ export const useTripStore = create((set, get) => ({ addTour: () => set((prev) => { const { tourInfo, activities } = prev; - if (!tourInfo.startTime || !tourInfo.endTime) return prev; + + if (!tourInfo.startTime) return prev; + + const { startTime } = tourInfo; + const endTime = tourInfo.endTime ?? startTime; const DAY = 60 * 60 * 24 * 1000; const days = - (Math.round(tourInfo.endTime.getTime() - tourInfo.startTime.getTime()) + - 1) / - DAY; + (Math.round(endTime.getTime() - startTime.getTime()) + 1) / DAY; return { ...prev, @@ -132,7 +136,7 @@ export const useTripStore = create((set, get) => ({ ...tourInfo, endTime: days >= activities.length - ? new Date(tourInfo.endTime.getTime() + DAY) + ? new Date(endTime.getTime() + DAY) : tourInfo.endTime, }, activities: [...prev.activities, []], @@ -180,42 +184,22 @@ export const useTripStore = create((set, get) => ({ ), })), - fillActivities: (items) => + fillActivities: (day, items) => set((prev) => { const { activities } = prev; const newActivities = [...activities]; - items.forEach((activity) => { - const targetTime = activity.dayTime; - - let flag = false; - newActivities.some((acts, index) => { - if (acts.find((act) => act.dayTime === targetTime)) return false; - acts.push({ ...activity, dayNumber: index + 1 }); - acts.sort((a, b) => TIME_ORDER[a.dayTime] - TIME_ORDER[b.dayTime]); - flag = true; - return true; - }); - - if (flag) return; - - newActivities.forEach((acts, index) => { - if (acts.length === 4) return; - - const remaining = acts.reduce< - ('MORNING' | 'AFTERNOON' | 'EVENING' | 'NIGHT')[] - >( - (res, curr) => res.filter((v) => v !== curr.dayTime), - ['MORNING', 'AFTERNOON', 'EVENING', 'NIGHT'], - ); - - const time = remaining.shift(); - if (time) { - acts.push({ ...activity, dayTime: time, dayNumber: index + 1 }); - acts.sort((a, b) => TIME_ORDER[a.dayTime] - TIME_ORDER[b.dayTime]); - } - }); + items.forEach((activity, index) => { + if ( + newActivities[day - 1].some((act) => act.dayTime === activity.dayTime) + ) + return; + + newActivities[day - 1].push({ ...activity, dayNumber: index + 1 }); + newActivities[day - 1].sort( + (a, b) => TIME_ORDER[a.dayTime] - TIME_ORDER[b.dayTime], + ); }); return { ...prev, activities: newActivities }; @@ -238,4 +222,6 @@ export const useTripStore = create((set, get) => ({ return { ...prev, activities: newActivities }; }), + + setSelectedDay: (day) => set((prev) => ({ ...prev, selectedDay: day })), })); diff --git a/src/providers/AuthProvider.tsx b/src/providers/AuthProvider.tsx index ba75b65..dd2f6f1 100644 --- a/src/providers/AuthProvider.tsx +++ b/src/providers/AuthProvider.tsx @@ -1,14 +1,14 @@ 'use client'; import axios from 'axios'; -import { useRouter, usePathname } from 'next/navigation'; +import { usePathname, useRouter } from 'next/navigation'; import { createContext, - useLayoutEffect, useCallback, - useState, - useMemo, useEffect, + useLayoutEffect, + useMemo, + useState, } from 'react'; import { useToast } from '@/features/toast'; @@ -120,7 +120,8 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { async (error) => { if ( error.response?.status === 500 && - error.response?.data.data === '필터 내부의 예외가 발생했습니다.' + error.response?.data.data === '필터 내부의 예외가 발생했습니다.' && + !error.response?.request.responseURL.endsWith('namechange') ) { createToast('error', '서버와의 통신에 실패하였습니다.'); } diff --git a/src/providers/ToastProvider.tsx b/src/providers/ToastProvider.tsx index 3edc73c..f7b41ba 100644 --- a/src/providers/ToastProvider.tsx +++ b/src/providers/ToastProvider.tsx @@ -29,22 +29,25 @@ interface ToastContainer { function ToastItem({ toast }: { toast: Toast }) { const [visible, setVisible] = useState(true); const { removeToast } = useToastStore(); - const { id, type, message } = toast; + const { id, type, message, duration } = toast; useEffect(() => { const timer = setTimeout(() => { setVisible(false); - }, DURATION); + }, duration ?? DURATION); - const removeTimer = setTimeout(() => { - removeToast(id); - }, DURATION + ANIMATION); + const removeTimer = setTimeout( + () => { + removeToast(id); + }, + (duration ?? DURATION) + ANIMATION, + ); return () => { clearTimeout(timer); clearTimeout(removeTimer); }; - }, [id, removeToast]); + }, [id, removeToast, duration]); return ( {message} @@ -64,13 +67,13 @@ const fadeOut = keyframes` const getToastColors = (type: ToastType) => { switch (type) { case 'success': - return { backgroundColor: '#6B67F9' }; + return { backgroundColor: '#f7f7fc' }; case 'error': return { backgroundColor: '#FF6F61' }; case 'info': - return { backgroundColor: '#505050' }; + return { backgroundColor: '#f7f7fc' }; default: - return { backgroundColor: '#505050' }; + return { backgroundColor: '#f7f7fc' }; } }; @@ -84,13 +87,12 @@ const styles = { align-items: center; flex-direction: column; gap: 1rem; - overflow: hidden; `, container: styled.div` width: 90%; border-radius: 8px; - color: #fff; + color: ${({ $type }) => ($type === 'error' ? 'white' : '#505050')}; font-family: 'Noto Sans KR'; font-size: 1rem; font-style: normal; @@ -100,6 +102,7 @@ const styles = { background: ${({ $type }) => getToastColors($type).backgroundColor}; text-align: center; padding: 1rem; + box-shadow: 0px 1px 4px 0px #6e80913d; animation: ${({ $visible }) => ($visible ? fadeIn : fadeOut)} 0.5s ease; transition: opacity 0.5s ease,