diff --git a/package.json b/package.json index 10b3be84..611f6d99 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ "react-beautiful-dnd": "^13.1.1", "react-dom": "^18.2.0", "react-hook-form": "^7.49.2", + "react-icons": "^5.0.1", "react-infinite-scroller": "^1.2.6", "react-kakao-maps-sdk": "^1.1.24", "react-modal": "^3.16.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0720f243..71bfd5fe 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -77,6 +77,9 @@ dependencies: react-hook-form: specifier: ^7.49.2 version: 7.49.3(react@18.2.0) + react-icons: + specifier: ^5.0.1 + version: 5.0.1(react@18.2.0) react-infinite-scroller: specifier: ^1.2.6 version: 1.2.6(react@18.2.0) @@ -5145,6 +5148,14 @@ packages: react: 18.2.0 dev: false + /react-icons@5.0.1(react@18.2.0): + resolution: {integrity: sha512-WqLZJ4bLzlhmsvme6iFdgO8gfZP17rfjYEJ2m9RsZjZ+cc4k1hTzknEz63YS1MeT50kVzoa1Nz36f4BEx+Wigw==} + peerDependencies: + react: '*' + dependencies: + react: 18.2.0 + dev: false + /react-infinite-scroller@1.2.6(react@18.2.0): resolution: {integrity: sha512-mGdMyOD00YArJ1S1F3TVU9y4fGSfVVl6p5gh/Vt4u99CJOptfVu/q5V/Wlle72TMgYlBwIhbxK5wF0C/R33PXQ==} peerDependencies: diff --git a/public/mockServiceWorker.js b/public/mockServiceWorker.js deleted file mode 100644 index e180e4c6..00000000 --- a/public/mockServiceWorker.js +++ /dev/null @@ -1,338 +0,0 @@ -/* eslint-disable */ -/* tslint:disable */ - -/** - * Mock Service Worker (0.36.3). - * @see https://github.com/mswjs/msw - * - Please do NOT modify this file. - * - Please do NOT serve this file on production. - */ - -const INTEGRITY_CHECKSUM = '02f4ad4a2797f85668baf196e553d929'; -const bypassHeaderName = 'x-msw-bypass'; -const activeClientIds = new Set(); - -self.addEventListener('install', function () { - return self.skipWaiting(); -}); - -self.addEventListener('activate', async function (event) { - return self.clients.claim(); -}); - -self.addEventListener('message', async function (event) { - const clientId = event.source.id; - - if (!clientId || !self.clients) { - return; - } - - const client = await self.clients.get(clientId); - - if (!client) { - return; - } - - const allClients = await self.clients.matchAll(); - - switch (event.data) { - case 'KEEPALIVE_REQUEST': { - sendToClient(client, { - type: 'KEEPALIVE_RESPONSE', - }); - break; - } - - case 'INTEGRITY_CHECK_REQUEST': { - sendToClient(client, { - type: 'INTEGRITY_CHECK_RESPONSE', - payload: INTEGRITY_CHECKSUM, - }); - break; - } - - case 'MOCK_ACTIVATE': { - activeClientIds.add(clientId); - - sendToClient(client, { - type: 'MOCKING_ENABLED', - payload: true, - }); - break; - } - - case 'MOCK_DEACTIVATE': { - activeClientIds.delete(clientId); - break; - } - - case 'CLIENT_CLOSED': { - activeClientIds.delete(clientId); - - const remainingClients = allClients.filter((client) => { - return client.id !== clientId; - }); - - // Unregister itself when there are no more clients - if (remainingClients.length === 0) { - self.registration.unregister(); - } - - break; - } - } -}); - -// Resolve the "main" client for the given event. -// Client that issues a request doesn't necessarily equal the client -// that registered the worker. It's with the latter the worker should -// communicate with during the response resolving phase. -async function resolveMainClient(event) { - const client = await self.clients.get(event.clientId); - - if (client.frameType === 'top-level') { - return client; - } - - const allClients = await self.clients.matchAll(); - - return allClients - .filter((client) => { - // Get only those clients that are currently visible. - return client.visibilityState === 'visible'; - }) - .find((client) => { - // Find the client ID that's recorded in the - // set of clients that have registered the worker. - return activeClientIds.has(client.id); - }); -} - -async function handleRequest(event, requestId) { - const client = await resolveMainClient(event); - const response = await getResponse(event, client, requestId); - - // Send back the response clone for the "response:*" life-cycle events. - // Ensure MSW is active and ready to handle the message, otherwise - // this message will pend indefinitely. - if (client && activeClientIds.has(client.id)) { - (async function () { - const clonedResponse = response.clone(); - sendToClient(client, { - type: 'RESPONSE', - payload: { - requestId, - type: clonedResponse.type, - ok: clonedResponse.ok, - status: clonedResponse.status, - statusText: clonedResponse.statusText, - body: - clonedResponse.body === null ? null : await clonedResponse.text(), - headers: serializeHeaders(clonedResponse.headers), - redirected: clonedResponse.redirected, - }, - }); - })(); - } - - return response; -} - -async function getResponse(event, client, requestId) { - const { request } = event; - const requestClone = request.clone(); - const getOriginalResponse = () => fetch(requestClone); - - // Bypass mocking when the request client is not active. - if (!client) { - return getOriginalResponse(); - } - - // Bypass initial page load requests (i.e. static assets). - // The absence of the immediate/parent client in the map of the active clients - // means that MSW hasn't dispatched the "MOCK_ACTIVATE" event yet - // and is not ready to handle requests. - if (!activeClientIds.has(client.id)) { - return await getOriginalResponse(); - } - - // Bypass requests with the explicit bypass header - if (requestClone.headers.get(bypassHeaderName) === 'true') { - const cleanRequestHeaders = serializeHeaders(requestClone.headers); - - // Remove the bypass header to comply with the CORS preflight check. - delete cleanRequestHeaders[bypassHeaderName]; - - const originalRequest = new Request(requestClone, { - headers: new Headers(cleanRequestHeaders), - }); - - return fetch(originalRequest); - } - - // Send the request to the client-side MSW. - const reqHeaders = serializeHeaders(request.headers); - const body = await request.text(); - - const clientMessage = await sendToClient(client, { - type: 'REQUEST', - payload: { - id: requestId, - url: request.url, - method: request.method, - headers: reqHeaders, - cache: request.cache, - mode: request.mode, - credentials: request.credentials, - destination: request.destination, - integrity: request.integrity, - redirect: request.redirect, - referrer: request.referrer, - referrerPolicy: request.referrerPolicy, - body, - bodyUsed: request.bodyUsed, - keepalive: request.keepalive, - }, - }); - - switch (clientMessage.type) { - case 'MOCK_SUCCESS': { - return delayPromise( - () => respondWithMock(clientMessage), - clientMessage.payload.delay, - ); - } - - case 'MOCK_NOT_FOUND': { - return getOriginalResponse(); - } - - case 'NETWORK_ERROR': { - const { name, message } = clientMessage.payload; - const networkError = new Error(message); - networkError.name = name; - - // Rejecting a request Promise emulates a network error. - throw networkError; - } - - case 'INTERNAL_ERROR': { - const parsedBody = JSON.parse(clientMessage.payload.body); - - console.error( - `\ -[MSW] Uncaught exception in the request handler for "%s %s": - -${parsedBody.location} - -This exception has been gracefully handled as a 500 response, however, it's strongly recommended to resolve this error, as it indicates a mistake in your code. If you wish to mock an error response, please see this guide: https://mswjs.io/docs/recipes/mocking-error-responses\ -`, - request.method, - request.url, - ); - - return respondWithMock(clientMessage); - } - } - - return getOriginalResponse(); -} - -self.addEventListener('fetch', function (event) { - const { request } = event; - const accept = request.headers.get('accept') || ''; - - // Bypass server-sent events. - if (accept.includes('text/event-stream')) { - return; - } - - // Bypass navigation requests. - if (request.mode === 'navigate') { - return; - } - - // Opening the DevTools triggers the "only-if-cached" request - // that cannot be handled by the worker. Bypass such requests. - if (request.cache === 'only-if-cached' && request.mode !== 'same-origin') { - return; - } - - // Bypass all requests when there are no active clients. - // Prevents the self-unregistered worked from handling requests - // after it's been deleted (still remains active until the next reload). - if (activeClientIds.size === 0) { - return; - } - - const requestId = uuidv4(); - - return event.respondWith( - handleRequest(event, requestId).catch((error) => { - if (error.name === 'NetworkError') { - console.warn( - '[MSW] Successfully emulated a network error for the "%s %s" request.', - request.method, - request.url, - ); - return; - } - - // At this point, any exception indicates an issue with the original request/response. - console.error( - `\ -[MSW] Caught an exception from the "%s %s" request (%s). This is probably not a problem with Mock Service Worker. There is likely an additional logging output above.`, - request.method, - request.url, - `${error.name}: ${error.message}`, - ); - }), - ); -}); - -function serializeHeaders(headers) { - const reqHeaders = {}; - headers.forEach((value, name) => { - reqHeaders[name] = reqHeaders[name] - ? [].concat(reqHeaders[name]).concat(value) - : value; - }); - return reqHeaders; -} - -function sendToClient(client, message) { - return new Promise((resolve, reject) => { - const channel = new MessageChannel(); - - channel.port1.onmessage = (event) => { - if (event.data && event.data.error) { - return reject(event.data.error); - } - - resolve(event.data); - }; - - client.postMessage(JSON.stringify(message), [channel.port2]); - }); -} - -function delayPromise(cb, duration) { - return new Promise((resolve) => { - setTimeout(() => resolve(cb()), duration); - }); -} - -function respondWithMock(clientMessage) { - return new Response(clientMessage.payload.body, { - ...clientMessage.payload, - headers: clientMessage.payload.headers, - }); -} - -function uuidv4() { - return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) { - const r = (Math.random() * 16) | 0; - const v = c == 'x' ? r : (r & 0x3) | 0x8; - return v.toString(16); - }); -} diff --git a/src/@types/member.types.ts b/src/@types/member.types.ts index 3b8146ac..2a6f4767 100644 --- a/src/@types/member.types.ts +++ b/src/@types/member.types.ts @@ -18,7 +18,7 @@ interface MemberInfo { // | 'TEENAGER' // | 'TWENTIES' // | 'THIRTIES' - // | 'FOURTIES' + // | 'FORTIES' // | 'ABOVE_FIFTIES' // | 'DEFATULT' genderType: string | null; diff --git a/src/@types/service.ts b/src/@types/service.ts index bae375b2..b0fe82ae 100644 --- a/src/@types/service.ts +++ b/src/@types/service.ts @@ -97,12 +97,26 @@ export type subBudgetRes = { } | null; }; +export type subCursorRes = { + status: number; + message: string; + data: { + tripId: string; + visitDate: string; + memberId: number; + name: string; + x: number; + y: number; + } | null; +}; + export type SocketContextType = { tripInfo: subInfoRes | null; tripItem: subItemRes | null; tripPath: subPathRes | null; tripMember: subMemberRes | null; tripBudget: subBudgetRes | null; + tripCursor: subCursorRes | null; tripId: string; callBackPub: (callback: () => void) => void; }; diff --git a/src/@types/socket.types.ts b/src/@types/socket.types.ts index 49244096..227a7467 100644 --- a/src/@types/socket.types.ts +++ b/src/@types/socket.types.ts @@ -82,6 +82,19 @@ type subBudgetMessage = (response: { }; }) => void; +type subCursorMessage = (response: { + status: number; + message: string; + data: { + tripId: string; + visitDate: string; + memberId: number; + name: string; + x: number; + y: number; + }; +}) => void; + interface pubInfo { startDate: string; endDate: string; @@ -100,7 +113,7 @@ interface pubAddTripItem { interface pubUpdatePrice { tripId: string; visitDate: string; - price: number; + price: string; } interface pubUpdateTripItem { @@ -138,3 +151,10 @@ interface pubGetPathAndItems { interface pubUpdateBudget { budget: number; } + +interface pubCursor { + token: string; + visitDate: string; + x: number; + y: number; +} diff --git a/src/@types/trips.types.ts b/src/@types/trips.types.ts index 6b6bceec..e2ebbb0c 100644 --- a/src/@types/trips.types.ts +++ b/src/@types/trips.types.ts @@ -13,6 +13,7 @@ interface MyTripType { startDate: string; endDate: string; numberOfPeople: number; + numberOfTripMember: number; tripStatus: string; tripThumbnailUrl: string; area: string; diff --git a/src/api/socket.ts b/src/api/socket.ts index 0ade3027..ff4101db 100644 --- a/src/api/socket.ts +++ b/src/api/socket.ts @@ -61,6 +61,18 @@ export const subBudget = ( }); }; +// 커서 공유 +export const subCursor = ( + tripId: string, + visitDate: string, + subCursorMessage: subCursorMessage, +) => { + socketClient.subscribe(`/sub/${tripId}/cursor/${visitDate}`, (message) => { + const res = JSON.parse(message.body); + subCursorMessage(res); + }); +}; + // 소켓 전송 // 여정 기본 정보 변경 이벤트 발생시 export const pubInfo = (pubInfo: pubInfo, tripId: string) => { @@ -84,7 +96,7 @@ export const pubAddTripItem = ( // 여행 아이템 예상 가격 업데이트 이벤트 발생시 export const pubUpdatePrice = ( pubUpdatePrice: pubUpdatePrice, - tripItemId: string, + tripItemId: number, ) => { socketClient.publish({ destination: `/pub/tripItems/${tripItemId}/updatePrice`, @@ -106,10 +118,10 @@ export const pubUpdateTripItem = ( // 여행 날짜별 교통 수단 변경 이벤트 발생시 (01/16 업데이트) export const pubUpdateTransportation = ( pubUpdateTransportation: pubUpdateTransportation, - trips: string, + tripId: string, ) => { socketClient.publish({ - destination: `/pub/trips/${trips}/updateTransportation`, + destination: `/pub/trips/${tripId}/updateTransportation`, body: JSON.stringify(pubUpdateTransportation), }); }; @@ -142,6 +154,7 @@ export const pubConnectMember = (pubMember: pubMember, tripId: string) => { destination: `/pub/trips/${tripId}/connectMember`, body: JSON.stringify(pubMember), }); + console.log('입장발생'); }; // 멤버 여정 페이지 퇴장 이벤트 발생시 @@ -150,6 +163,7 @@ export const pubDisconnectMember = (pubMember: pubMember, tripId: string) => { destination: `/pub/trips/${tripId}/disconnectMember`, body: JSON.stringify(pubMember), }); + console.log('퇴장발생'); }; // 여정 편집 페이지 입장 이벤트 발생시(모든 sub 다받음) @@ -187,3 +201,11 @@ export const pubUpdateBudget = ( body: JSON.stringify(pubUpdateBudget), }); }; + +// 커서공유 +export const pubCursor = (pubCursor: pubCursor, tripId: string) => { + socketClient.publish({ + destination: `/pub/trips/${tripId}/cursor`, + body: JSON.stringify(pubCursor), + }); +}; diff --git a/src/api/trips.ts b/src/api/trips.ts index 0a89bb54..a33f5e38 100644 --- a/src/api/trips.ts +++ b/src/api/trips.ts @@ -21,7 +21,7 @@ export const putTrips = async ( // 여행 상세페이지에서 여정에 여행지 등록 export const postTripsItem = async ( tripId: string, - tourItemId: number, + tourItemId: string, visitDate: string, ) => { const requestBody = { diff --git a/src/components/Auth/AuthSurvey/AuthSurvey.tsx b/src/components/Auth/AuthSurvey/AuthSurvey.tsx index 7486b522..a4d02834 100644 --- a/src/components/Auth/AuthSurvey/AuthSurvey.tsx +++ b/src/components/Auth/AuthSurvey/AuthSurvey.tsx @@ -9,7 +9,7 @@ import { UserInfoState } from '@recoil/Auth.atom'; import { useRecoilState } from 'recoil'; interface Props { - path: string; + path?: string; } const AuthSurvey = ({ path }: Props) => { @@ -42,7 +42,11 @@ const AuthSurvey = ({ path }: Props) => { // newPrevUserInfo.survey = data; // return newPrevUserInfo; // }); - navigate(path); + if (path) { + navigate(path); + } else { + navigate(-1); + } } } catch (err) { console.error(err); diff --git a/src/components/DatePicker/Calendar.tsx b/src/components/DatePicker/Calendar.tsx index 1db551ab..16eb0172 100644 --- a/src/components/DatePicker/Calendar.tsx +++ b/src/components/DatePicker/Calendar.tsx @@ -173,7 +173,7 @@ const Calendar: React.FC<{ : '날짜를 선택해주세요.'} -
+
{visibleMonths.map((month, idx) => (
{renderCalendar(month)}
))} diff --git a/src/components/DetailSectionBottom/DetailReviewStats.tsx b/src/components/DetailSectionBottom/DetailReviewStats.tsx index f87f181b..7a2342b4 100644 --- a/src/components/DetailSectionBottom/DetailReviewStats.tsx +++ b/src/components/DetailSectionBottom/DetailReviewStats.tsx @@ -9,6 +9,7 @@ const DetailReviewStats = () => { const { reviewStats } = useGetToursReviews(); const { calculateWidth, getColor } = useReviewStatsCalculator(reviewStats); const [showAll, setShowAll] = useState(false); + console.log(reviewStats); return ( <> diff --git a/src/components/DetailSectionBottom/DetailReviews.tsx b/src/components/DetailSectionBottom/DetailReviews.tsx index fe0e7e83..8d526846 100644 --- a/src/components/DetailSectionBottom/DetailReviews.tsx +++ b/src/components/DetailSectionBottom/DetailReviews.tsx @@ -101,7 +101,6 @@ export default function DetailReviews({ reviewData }: reviewProps) { }; useEffect(() => { - console.log('toursReviews', toursReviews); { toursReviews?.pages.map((group) => { setReviewDataLength(group?.data.data.reviewTotalCount); diff --git a/src/components/DetailSectionBottom/ReviewItem.tsx b/src/components/DetailSectionBottom/ReviewItem.tsx index 4bc2caec..57a89c8c 100644 --- a/src/components/DetailSectionBottom/ReviewItem.tsx +++ b/src/components/DetailSectionBottom/ReviewItem.tsx @@ -1,4 +1,9 @@ -import { StarIcon, ChatIcon, MoreIcon } from '@components/common/icons/Icons'; +import { + StarIcon, + ChatIcon, + MoreIcon, + UserIcon, +} from '@components/common/icons/Icons'; import { useSetRecoilState, useRecoilState } from 'recoil'; import { isModalOpenState, @@ -16,7 +21,6 @@ import { import { MouseEvent, useState } from 'react'; import { getEmoji } from '@utils/utils'; import { getStarFill } from '@utils/getStarFill'; -import { ReactComponent as NullUser } from '@assets/images/NullUser.svg'; interface Keyword { keywordId: number; @@ -121,16 +125,19 @@ const Item: React.FC = (props: ItemProps) => { 유저 프로필 ) : ( - +
+ +
)}
-
{authorNickname}
+
{authorNickname}
-
+
{Array.from({ length: 5 }, (_, index) => ( = (props: ItemProps) => { /> ))}
-
+
{formatCreatedTime(createdTime)}
@@ -160,7 +167,7 @@ const Item: React.FC = (props: ItemProps) => { {content.length > 55 ? `${content.slice(0, 55)}...` : content}
) : ( -
{content}
+
{content}
)}
@@ -199,7 +206,7 @@ const Item: React.FC = (props: ItemProps) => { .map((keyword, idx) => (
+ className="rounded-md bg-gray1 px-2 py-1 text-gray6"> {getEmoji(keyword.content)} {keyword.content}
))} diff --git a/src/components/DetailSectionTop/DetailAddSchedule.tsx b/src/components/DetailSectionTop/DetailAddSchedule.tsx index 57880903..fbbc8361 100644 --- a/src/components/DetailSectionTop/DetailAddSchedule.tsx +++ b/src/components/DetailSectionTop/DetailAddSchedule.tsx @@ -1,118 +1,255 @@ import * as Dialog from '@radix-ui/react-dialog'; -import { CalendarIcon } from '@components/common/icons/Icons'; +import { CalendarIcon, GrayCalendarIcon } from '@components/common/icons/Icons'; import Alert from '@components/common/alert/Alert'; import { useNavigate } from 'react-router-dom'; import { PlusIcon } from '@components/common/icons/Icons'; import { useGetMyTrips } from '@hooks/useGetMyTrips'; import { calculateTripDuration } from '@utils/calculateTripDuration'; import { calculateDayAndDate } from '@utils/utils'; +import Accordion from '@components/common/accordion/Accordion'; +import { useState, useEffect } from 'react'; +import { postTripsItem } from '@api/trips'; +import { useParams } from 'react-router-dom'; +import { Swiper, SwiperSlide } from 'swiper/react'; const DetailAddSchedule = () => { - const { myTrips } = useGetMyTrips(); + const token = localStorage.getItem('accessToken'); - const { SmallDayArr } = calculateDayAndDate( - myTrips[0]?.startDate, - myTrips[0]?.endDate, + const navigate = useNavigate(); + const { id: tourItemId } = useParams(); + const { myTrips } = useGetMyTrips(); + const [isOpen, setIsOpen] = useState(false); + const [openAccordion, setOpenAccordion] = useState(''); + const [isProcessing, setIsProcessing] = useState(false); + const [selectedVisitDate, setSelectedVisitDate] = useState( + null, + ); + const [selectedTripId, setSelectedTripId] = useState(null); + const [selectedButton, setSelectedButton] = useState(null); + const initialValue = myTrips.findIndex( + (trip) => trip.tripStatus !== '여행완료', ); - console.log(SmallDayArr); + const handleNavigate = (url: string) => { + navigate(url); + }; - const navigate = useNavigate(); + const handleDateButtonClick = ( + tripId: string | null, + visitDate: string | null, + index: number | null, + ) => { + setSelectedTripId(tripId); + setSelectedVisitDate(visitDate); + setSelectedButton(index); + }; - const handleConfirm = () => { - navigate('/login'); + const handleAccordion = (newValue: string) => { + setOpenAccordion(newValue); + setSelectedButton(null); }; - const handleCreate = () => { - navigate('/create'); + const handlePostTripsItem = async ( + tripId: string, + tourItemId: string, + visitDate: string, + ) => { + if (isProcessing) return; + setIsProcessing(true); + if (tripId && tourItemId && visitDate) { + try { + await postTripsItem(tripId, tourItemId, visitDate); + setIsOpen(false); + } catch (error) { + console.error('요청 실패:', error); + } finally { + setIsProcessing(false); + } + } }; - return ( - - - - + useEffect(() => { + setOpenAccordion(`item-${initialValue}`); + }, [initialValue]); - - + useEffect(() => { + if (!isOpen) { + setOpenAccordion(`item-${initialValue}`); + setSelectedButton(null); + } + }, [isOpen]); - - - - - - - {myTrips.map((trip, index) => { - // 각 여행에 대한 기간을 계산합니다. - const tripDuration = calculateTripDuration( - trip.startDate, - trip.endDate, - ); - - return ( -
-
-
- {`Thumbnail -
-
- {trip.tripName} -
-
- {trip.startDate?.replace(/-/g, '.')} -{' '} - {trip.endDate?.substring(5).replace(/-/g, '.')} ( - {tripDuration}) -
-
-
+ + + + 0 ? 'h-[392px]' : 'h-[276px]' + }`}> +
+
- ); - })} - -
-
-
- Day 1 -
+
-
- - - 일정 추가 시 로그인이 필요합니다. -
- 로그인 하시겠습니까? - - } - onConfirm={handleConfirm}> -
- -
-
-
-
- + {myTrips.length > 0 ? ( + + {myTrips.map((trip, index) => { + if (trip.tripStatus !== '여행완료') { + const tripDuration = calculateTripDuration( + trip.startDate, + trip.endDate, + ); + const { SmallDayArr, DateArr } = calculateDayAndDate( + trip.startDate, + trip.endDate, + ); + return ( + <> + +
+
+ {`Thumbnail +
+
+ {trip.tripName} +
+
+ {trip.startDate?.replace(/-/g, '.')} -{' '} + {trip.endDate + ?.substring(5) + .replace(/-/g, '.')}{' '} + {tripDuration === '0박 1일' + ? '' + : `(${tripDuration})`} +
+
+
+
+
+ } + content={ +
+ + {SmallDayArr.map((day, index) => ( + + + + ))} + +
+ } + /> + + ); + } + })} + + ) : ( + +
+ +

+ 등록된 여행이 없습니다. +

+
+
+ )} + {myTrips.length > 0 ? ( +
+ +
+ ) : ( + '' + )} + + + + ) : ( + + 새로운 여행 생성 시 로그인이 필요합니다. +
+ 로그인 하시겠습니까? + + } + onConfirm={() => handleNavigate('/login')}> + +
+ )} + ); }; diff --git a/src/components/DetailSectionTop/DetailSectionTop.tsx b/src/components/DetailSectionTop/DetailSectionTop.tsx index b6283042..7d4ad2c8 100644 --- a/src/components/DetailSectionTop/DetailSectionTop.tsx +++ b/src/components/DetailSectionTop/DetailSectionTop.tsx @@ -1,9 +1,7 @@ // import { useEffect, useState } from 'react'; import { useQuery } from '@tanstack/react-query'; import { useParams } from 'react-router-dom'; - import { getDetailTours, getToursReviews } from '@api/tours'; - import { DetailToursButtons, DetailToursInfo, diff --git a/src/components/DetailSectionTop/DetailToursMap.tsx b/src/components/DetailSectionTop/DetailToursMap.tsx index 4413e86f..07871ac0 100644 --- a/src/components/DetailSectionTop/DetailToursMap.tsx +++ b/src/components/DetailSectionTop/DetailToursMap.tsx @@ -1,7 +1,6 @@ -import { useState } from 'react'; import { Map, MapMarker } from 'react-kakao-maps-sdk'; import { useKakaoLoader } from 'react-kakao-maps-sdk'; -import { PhoneIcon, MapIcon, DownIcon } from '@components/common/icons/Icons'; +import { PhoneIcon, MapIcon } from '@components/common/icons/Icons'; const VITE_KAKAO_MAP_API_KEY = import.meta.env.VITE_KAKAO_MAP_API_KEY; @@ -11,8 +10,6 @@ interface DetailToursMapProps { export default function DetailToursMap({ mapData }: DetailToursMapProps) { const { fullAddress, longitude, latitude, tel } = mapData; - const [isMapVisible, setIsMapVisible] = useState(false); - const [isAddressVisible, setIsAddressVisible] = useState(false); const [_] = useKakaoLoader({ appkey: VITE_KAKAO_MAP_API_KEY, @@ -20,37 +17,13 @@ export default function DetailToursMap({ mapData }: DetailToursMapProps) { const MapStyle = { width: '100%', - height: isMapVisible ? 0 : '180px', - marginTop: isMapVisible ? '15px' : '15px', - marginBottom: isMapVisible ? '15px' : '15px', - transition: 'height 0.3s ease-in-out', - }; - - const closeMap = () => { - setIsMapVisible((prev) => !prev); + height: '180px', + marginTop: '5px', + marginBottom: '15px', }; return (
-
-
setIsAddressVisible(!isAddressVisible)}> - - {!isAddressVisible ? ( -

- {fullAddress} -

- ) : ( -

- {fullAddress} -

- )} -
-
- -
-
-
- -
-

- {tel ? tel : '전화번호가 없어요'} + +

+
+ +

+ {fullAddress}

-
-
+
+
+ +
+

+ {tel ? tel : '전화번호가 없어요'} +

+
+
+
diff --git a/src/components/DetailSectionTop/DetailToursRating.tsx b/src/components/DetailSectionTop/DetailToursRating.tsx index 2be49e9c..d92303d2 100644 --- a/src/components/DetailSectionTop/DetailToursRating.tsx +++ b/src/components/DetailSectionTop/DetailToursRating.tsx @@ -67,7 +67,6 @@ export default function DetailToursRating({ ); })} -

({reviewTotalCount}) diff --git a/src/components/MyTrip/MyTripIngItem.tsx b/src/components/MyTrip/MyTripIngItem.tsx index dcac90dd..ca0d08d4 100644 --- a/src/components/MyTrip/MyTripIngItem.tsx +++ b/src/components/MyTrip/MyTripIngItem.tsx @@ -37,7 +37,8 @@ const MyTripIngItem: React.FC = ({ myTripList }) => {

{startDate.replace(/-/g, '.')} ~{' '} - {endDate.replace(/-/g, '.').split('2024.')} ({tripDuration}) + {endDate.replace(/-/g, '.').split('2024.')}{' '} + {tripDuration === '0박 1일' ? null : ` (${tripDuration})`}
diff --git a/src/components/MyTrip/MyTripItem.tsx b/src/components/MyTrip/MyTripItem.tsx index f327aa9c..72375a66 100644 --- a/src/components/MyTrip/MyTripItem.tsx +++ b/src/components/MyTrip/MyTripItem.tsx @@ -21,7 +21,7 @@ const MyTripItem: React.FC = ({ myTripList }) => { tripName, startDate, endDate, - numberOfPeople, + numberOfTripMember, tripThumbnailUrl, } = myTripList; @@ -44,7 +44,7 @@ const MyTripItem: React.FC = ({ myTripList }) => { destructive={true} onClick={() => deleteMyTripMutate(tripId)}>
- 삭제 + 나가기
@@ -71,13 +71,14 @@ const MyTripItem: React.FC = ({ myTripList }) => {
{startDate.replace(/-/g, '.')} -{' '} - {endDate.replace(/-/g, '.').split('2024.')} ({tripDuration}) + {endDate.replace(/-/g, '.').split('2024.')} + {tripDuration === '0박 1일' ? null : ` (${tripDuration})`}
- {numberOfPeople}명과 공유중 + {numberOfTripMember}명과 공유중
diff --git a/src/components/Plan/PlanCursor.tsx b/src/components/Plan/PlanCursor.tsx new file mode 100644 index 00000000..48d10a01 --- /dev/null +++ b/src/components/Plan/PlanCursor.tsx @@ -0,0 +1,94 @@ +import { useEffect, useState, useContext } from 'react'; +import { BsFillCursorFill } from 'react-icons/bs'; +import { pubCursor } from '@api/socket'; +import { socketContext } from '@hooks/useSocket'; +import { useGetTripsAuthority } from '@hooks/useGetTripsAuthority'; + +type TripCursorData = { + memberId: number; + x: number; + y: number; + name: string; +}; + +type PlanCursorProps = { + date: string; +}; + +const PlanCursor = ({ date }: PlanCursorProps) => { + const token = localStorage.getItem('accessToken'); + const { memberId } = useGetTripsAuthority(); + const { callBackPub, tripId, tripMember } = useContext(socketContext); + const [position, setPosition] = useState({ x: 0, y: 0 }); + + const myName = tripMember?.data?.tripMembers.find( + (member) => member.memberId === memberId, + ); + + useEffect(() => { + const handleMouseMove = (e: MouseEvent) => { + setPosition({ x: e.clientX, y: e.clientY }); + }; + const cursorStyle = (style: string): void => { + document.querySelectorAll('*').forEach((el) => { + const element = el as HTMLElement; + element.style.cursor = style; + }); + }; + cursorStyle('none'); + document.addEventListener('mousemove', handleMouseMove); + return () => { + cursorStyle('auto'); + document.removeEventListener('mousemove', handleMouseMove); + }; + }, []); + + // useEffect(() => { + // if (token && position && myName && date && tripId) { + // const timeoutId = setTimeout(() => { + // callBackPub(() => + // pubCursor( + // { + // token: token, + // visitDate: date, + // x: position.x, + // y: position.y, + // }, + // tripId, + // ), + // ); + // }, 1000); + + // return () => clearTimeout(timeoutId); + // } + // }, [position]); + + useEffect(() => { + if (token && position && myName && date && tripId) { + callBackPub(() => + pubCursor( + { + token: token, + visitDate: date, + x: position.x, + y: position.y, + }, + tripId, + ), + ); + } + }, [position]); + + return ( +
+ +
+ {myName?.name} +
+
+ ); +}; + +export default PlanCursor; diff --git a/src/components/Plan/PlanEditItemBox.tsx b/src/components/Plan/PlanEditItemBox.tsx index 4c5ce80d..1e1a6fec 100644 --- a/src/components/Plan/PlanEditItemBox.tsx +++ b/src/components/Plan/PlanEditItemBox.tsx @@ -1,4 +1,4 @@ -import { PenIcon, DragAndDropIcon } from '@components/common/icons/Icons'; +import { DragAndDropIcon } from '@components/common/icons/Icons'; import { TripItem } from '@/@types/service'; import { DragDropContext, @@ -139,9 +139,10 @@ const PlanEditItemBox = ({ alt="img" />
-
- {item.name} - +
+ {item.name.length > 17 + ? item.name.slice(0, 17) + '...' + : item.name}
{item.category} @@ -166,7 +167,7 @@ const PlanEditItemBox = ({ )} -
+
= ({ date, day }) => { return ( <> - {tripPath && } + + + {tripPath && }
{tripAuthority !== 'WRITE' || isEdit ? (
@@ -116,6 +120,7 @@ const PlanItem: React.FC = ({ date, day }) => { item={tripItem?.data?.tripItems || []} paths={tripPath?.data?.paths || []} transportation={transpo} + visitDate={date || ''} day={day} /> )} @@ -127,7 +132,7 @@ const PlanItem: React.FC = ({ date, day }) => {
navigate('./place')} - className="h-[40px] w-full"> + className="h-[56px] w-full">
장소 추가하기
diff --git a/src/components/Plan/PlanItemBox.tsx b/src/components/Plan/PlanItemBox.tsx index d54a922c..7d269110 100644 --- a/src/components/Plan/PlanItemBox.tsx +++ b/src/components/Plan/PlanItemBox.tsx @@ -3,15 +3,22 @@ import { CarIcon, BusIcon, SequenceIcon, + CloseIcon, } from '@components/common/icons/Icons'; import { TripItem, Paths } from '@/@types/service'; import { v4 as uuidv4 } from 'uuid'; +import { useGetTripsAuthority } from '@hooks/useGetTripsAuthority'; +import Alert from '@components/common/alert/Alert'; +import { useContext, useState } from 'react'; +import { socketContext } from '@hooks/useSocket'; +import { pubUpdatePrice } from '@api/socket'; type PlanItemBoxProps = { item: TripItem[]; paths: Paths[]; transportation: string; day: string; + visitDate: string; }; const PlanItemBox = ({ @@ -19,12 +26,31 @@ const PlanItemBox = ({ paths, transportation, day, + visitDate, }: PlanItemBoxProps) => { if (!item || !paths) { return
Missing data
; } + const { tripAuthority } = useGetTripsAuthority(); + const { tripId } = useContext(socketContext); const itemLength = item.length; + const [inputPrice, setInputPrice] = useState(''); + const showCloseIcon = inputPrice; + + const handlePrice = (inputBudget: string, tripItemId: number) => { + if (inputBudget && tripItemId) { + pubUpdatePrice( + { + tripId: tripId, + visitDate: visitDate, + price: inputBudget, + }, + tripItemId, + ); + setInputPrice(''); + } + }; return ( <> @@ -41,22 +67,61 @@ const PlanItemBox = ({
-
+
img
-
- {item.name} - +
+ {item.name.length > 19 + ? item.name.slice(0, 19) + '...' + : item.name}
{item.category}
-
- {item.price} 원 +
+ {item.price.toLocaleString()} 원 + {tripAuthority == 'WRITE' && ( + + handlePrice(inputPrice, item.tripItemId) + } + closeOnConfirm={true} + children={ + + } + content={ +
+
+ setInputPrice(e.target.value)} + /> +
setInputPrice('')}> + {showCloseIcon && ( + + )} +
+
+ + 원 + +
+ } + /> + )}
@@ -76,9 +141,15 @@ const PlanItemBox = ({ ) : null}
- {(path.pathInfo.totalDistance / 1000).toFixed(2)}km,{' '} - {path.pathInfo.totalTime}분,{' '} - {path.pathInfo.price.toLocaleString()}원 + {path.pathInfo.totalDistance < 0 || + path.pathInfo.totalTime < 0 || + path.pathInfo.price < 0 + ? '경로 정보가 없습니다.' + : `${(path.pathInfo.totalDistance / 1000).toFixed( + 2, + )}km, ${ + path.pathInfo.totalTime + }분, ${path.pathInfo.price.toLocaleString()}원`}
diff --git a/src/components/Plan/PlanMoveItem.tsx b/src/components/Plan/PlanMoveItem.tsx index 0daa5b5f..fdfa5d84 100644 --- a/src/components/Plan/PlanMoveItem.tsx +++ b/src/components/Plan/PlanMoveItem.tsx @@ -85,17 +85,17 @@ const PlanMoveItem: React.FC = ({ - +
-
-

+

+

날짜 이동

-
-
+
+
{day.map((day, index) => (
- {trip?.startDate} ~ {trip?.endDate} + {trip?.startDate?.substring(2).replace(/-/g, '.') || ''} -{' '} + {trip?.endDate?.substring(5).replace(/-/g, '.') || ''}
); diff --git a/src/components/Plan/PlanSectionTop.tsx b/src/components/Plan/PlanSectionTop.tsx index 480ec571..fada423c 100644 --- a/src/components/Plan/PlanSectionTop.tsx +++ b/src/components/Plan/PlanSectionTop.tsx @@ -1,4 +1,4 @@ -import TripRealtimeEditor from '@components/Trip/TripRealtimeEditor'; +import TripRealtimeMember from '@components/Trip/TripRealtimeMember'; import { BackBox } from '@components/common'; import { useNavigate } from 'react-router-dom'; import TripBudget from './TripBudget'; @@ -92,7 +92,7 @@ const PlanSectionTop = () => { navigate(`/trip/${tripId}/share`); }} /> - + { const { tripAuthority } = useGetTripsAuthority(); - const { tripBudget, tripId } = useContext(socketContext); + const { callBackPub, tripBudget, tripId } = useContext(socketContext); const budget = tripBudget?.data; @@ -28,11 +28,13 @@ const TripBudget = () => { const handleSetTargetBudget = (inputBudget: string) => { const newTargetBudget = parseInt(inputBudget, 10); // 문자열 숫자로 변환 if (!isNaN(newTargetBudget) && newTargetBudget !== budget?.budget) { - pubUpdateBudget( - { - budget: newTargetBudget, - }, - tripId || '', + callBackPub(() => + pubUpdateBudget( + { + budget: newTargetBudget, + }, + tripId || '', + ), ); setInputBudget(''); } @@ -111,11 +113,11 @@ const TripBudget = () => { } content={ -
+
setInputBudget(e.target.value)} diff --git a/src/components/Plan/TripMap.tsx b/src/components/Plan/TripMap.tsx index 94dac763..60b40607 100644 --- a/src/components/Plan/TripMap.tsx +++ b/src/components/Plan/TripMap.tsx @@ -6,12 +6,16 @@ import { getColor } from '@utils/getColor'; const VITE_KAKAO_MAP_API_KEY = import.meta.env.VITE_KAKAO_MAP_API_KEY; const TripMap = ({ paths }: { paths: Paths[] }) => { + const DEFAULT_LATITUDE = 36.34; + const DEFAULT_LONGITUDE = 127.77; + const firstPath = paths[0]; - const latitude = firstPath?.fromLatitude; - const longitude = firstPath?.fromLongitude; + const latitude = firstPath ? firstPath.fromLatitude : DEFAULT_LATITUDE; + const longitude = firstPath ? firstPath.fromLongitude : DEFAULT_LONGITUDE; const mapRef = useRef(null); const [selectedMarker, setSelectedMarker] = useState(null); + const [maplevel, setMapLevel] = useState(4); const defaultPosition = { lat: Number(latitude), lng: Number(longitude) }; @@ -84,7 +88,13 @@ const TripMap = ({ paths }: { paths: Paths[] }) => { }; useEffect(() => { - setBounds(); + if (paths.length > 0) { + setTimeout(() => { + setBounds(); + }, 100); + } else { + setMapLevel(15); + } }, [paths]); // 마커를 클릭할 때 호출되는 함수 @@ -186,39 +196,40 @@ const TripMap = ({ paths }: { paths: Paths[] }) => { key={VITE_KAKAO_MAP_API_KEY} center={centerPosition} style={MapStyle} - level={4} + level={maplevel} className="relative object-fill" ref={mapRef}> - {paths.map((path, index) => ( -
- handleMarkerClick(path.toSeqNum)} - image={getSequenceIconUrl(path.toSeqNum - 1)} - /> - {/* 마지막 항목인 경우, 목적지 위치에 마커 추가 */} - {index === paths.length - 1 && ( + {paths.length !== 0 && + paths.map((path, index) => ( +
handleMarkerClick(path.toSeqNum + 1)} - image={getSequenceIconUrl(path.toSeqNum)} + onClick={() => handleMarkerClick(path.toSeqNum)} + image={getSequenceIconUrl(path.toSeqNum - 1)} /> - )} - -
- ))} + {/* 마지막 항목인 경우, 목적지 위치에 마커 추가 */} + {index === paths.length - 1 && ( + handleMarkerClick(path.toSeqNum + 1)} + image={getSequenceIconUrl(path.toSeqNum)} + /> + )} + +
+ ))}
); diff --git a/src/components/Review/CommentItem.tsx b/src/components/Review/CommentItem.tsx index 33712b0c..6c1b907e 100644 --- a/src/components/Review/CommentItem.tsx +++ b/src/components/Review/CommentItem.tsx @@ -1,13 +1,11 @@ -import { MoreIcon } from '@components/common/icons/Icons'; +import { MoreIcon, UserIcon } from '@components/common/icons/Icons'; import { isModalOpenState, - titleState, modalChildrenState, + titleState, } from '@recoil/modal'; import { commentState, targetCommentIdState } from '@recoil/review'; import { useRecoilState, useSetRecoilState } from 'recoil'; -import { ReactComponent as NullUser } from '@assets/images/NullUser.svg'; - interface ItemProps { commentId: number; authorNickname: string; @@ -55,7 +53,7 @@ const CommentItem: React.FC = (props: ItemProps) => { }; return ( -
+
{!( @@ -65,10 +63,13 @@ const CommentItem: React.FC = (props: ItemProps) => { 유저 프로필 ) : ( - +
+ +
)}
@@ -85,7 +86,7 @@ const CommentItem: React.FC = (props: ItemProps) => {
)}
-
{content}
+
{content}
); }; diff --git a/src/components/Review/ReviewButton.tsx b/src/components/Review/ReviewButton.tsx index 979354f2..ca1209a5 100644 --- a/src/components/Review/ReviewButton.tsx +++ b/src/components/Review/ReviewButton.tsx @@ -1,6 +1,6 @@ import { ButtonPrimary } from '@components/common/button/Button'; import { useState, useEffect } from 'react'; -import { contentState, keywordsState } from '@recoil/review'; +import { contentState, keywordsState, ratingState } from '@recoil/review'; import { useRecoilState, useRecoilValue } from 'recoil'; interface ButtonProps { @@ -11,8 +11,10 @@ const ReviewButton = (props: ButtonProps) => { const { onClick } = props; const [content] = useRecoilState(contentState); const keywords = useRecoilValue(keywordsState); + const rating = useRecoilValue(ratingState); const [isContentValid, setIsContentValid] = useState(false); const [isKeywordsValid, setIsKeywordsValid] = useState(false); + const [isRatingValid, setIsRatingValid] = useState(false); useEffect(() => { if (content.length >= 10) { @@ -30,18 +32,37 @@ const ReviewButton = (props: ButtonProps) => { } }, [keywords]); + useEffect(() => { + if (rating > 0) { + setIsRatingValid(true); + } else if (rating <= 0) { + setIsRatingValid(false); + } + }, [rating]); + return (
- {isContentValid === false && isKeywordsValid === false && ( + {isRatingValid === false && (
- 키워드를 선택하거나 텍스트를 10자 이상 입력해주세요 + 별점을 입력해주세요
)} + {isRatingValid === true && + isContentValid === false && + isKeywordsValid === false && ( +
+ 키워드를 선택하거나 텍스트를 10자 이상 입력해주세요 +
+ )} + + className="flex h-[56px] items-center justify-center" + disabled={ + isRatingValid === false || + (isContentValid === false && isKeywordsValid === false) + }> 완료
diff --git a/src/components/Review/ReviewComments.tsx b/src/components/Review/ReviewComments.tsx index 208fbd83..16fc7a00 100644 --- a/src/components/Review/ReviewComments.tsx +++ b/src/components/Review/ReviewComments.tsx @@ -10,8 +10,9 @@ import React, { useEffect, useState } from 'react'; import InfiniteScroll from 'react-infinite-scroller'; import EditDelete from '@components/common/modal/children/EditDelete'; import MyAlert from '@components/common/modal/children/MyAlert'; -import { commentState } from '@recoil/review'; +import { commentState, toastPopUpState } from '@recoil/review'; import { alertTypeState } from '@recoil/modal'; +import ToastPopUp from '@components/common/toastpopup/ToastPopUp'; export default function ReviewComments() { const params = useParams(); @@ -23,6 +24,7 @@ export default function ReviewComments() { const modalChildren = useRecoilValue(modalChildrenState); const setComment = useSetRecoilState(commentState); const alertType = useRecoilValue(alertTypeState); + const [toastPopUp, setToastPopUp] = useRecoilState(toastPopUpState); const { data: reviewComments, @@ -61,12 +63,29 @@ export default function ReviewComments() { console.log('reviewComments', reviewComments); }, [reviewComments]); + useEffect(() => { + if (toastPopUp.isPopUp) { + const timer = setTimeout(() => { + setToastPopUp(() => ({ + isPopUp: false, + noun: '', + verb: '', + })); + }, 2000); + return () => clearTimeout(timer); + } + }, [toastPopUp]); + return ( <> + {toastPopUp.isPopUp && ( + + )}
댓글 {commentDataLength}
+
{commentDataLength == 0 && (
@@ -118,6 +137,9 @@ export default function ReviewComments() { content="댓글 쓰기 시 로그인이 필요해요. 로그인하시겠어요?" /> )} + {modalChildren === 'MyAlert' && alertType === 'DeleteComment' && ( + + )} ); diff --git a/src/components/Review/ReviewPosting.tsx b/src/components/Review/ReviewPosting.tsx index c7ea1e8c..e266e39d 100644 --- a/src/components/Review/ReviewPosting.tsx +++ b/src/components/Review/ReviewPosting.tsx @@ -15,9 +15,9 @@ export default function ReviewPosting() { return (
리뷰를 작성해주세요
-
+