diff --git a/public/styles/casual.jpg b/public/styles/casual.jpg new file mode 100644 index 00000000..29573433 Binary files /dev/null and b/public/styles/casual.jpg differ diff --git a/public/styles/classic.jpg b/public/styles/classic.jpg new file mode 100644 index 00000000..d0418bc4 Binary files /dev/null and b/public/styles/classic.jpg differ diff --git a/public/styles/feminine.jpg b/public/styles/feminine.jpg new file mode 100644 index 00000000..3bb0df49 Binary files /dev/null and b/public/styles/feminine.jpg differ diff --git a/public/styles/formal.jpg b/public/styles/formal.jpg new file mode 100644 index 00000000..f0252c50 Binary files /dev/null and b/public/styles/formal.jpg differ diff --git a/public/styles/hip.jpg b/public/styles/hip.jpg new file mode 100644 index 00000000..a4a7a2d6 Binary files /dev/null and b/public/styles/hip.jpg differ diff --git a/public/styles/luxury.jpg b/public/styles/luxury.jpg new file mode 100644 index 00000000..b466eb6a Binary files /dev/null and b/public/styles/luxury.jpg differ diff --git a/public/styles/minimal.jpg b/public/styles/minimal.jpg new file mode 100644 index 00000000..1751fc61 Binary files /dev/null and b/public/styles/minimal.jpg differ diff --git a/public/styles/outdoor.jpg b/public/styles/outdoor.jpg new file mode 100644 index 00000000..e33c7872 Binary files /dev/null and b/public/styles/outdoor.jpg differ diff --git a/public/styles/street.jpg b/public/styles/street.jpg new file mode 100644 index 00000000..c48b29f0 Binary files /dev/null and b/public/styles/street.jpg differ diff --git a/src/App.tsx b/src/App.tsx index e2069bac..175c94a0 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useEffect, useState } from 'react'; import { BrowserRouter, Route, Routes, Navigate } from 'react-router-dom'; import Home from '@pages/Home'; @@ -7,6 +7,7 @@ import LoginComplete from '@pages/Login/LoginComplete'; import SignUp from '@pages/SignUp'; import TermsAgreement from '@pages/SignUp/TermsAgreement'; +import PickMyStyle from '@pages/SignUp/PickMyStyle'; import Profile from '@pages/Profile'; import ProfileEdit from '@pages/Profile/ProfileEdit'; @@ -24,11 +25,29 @@ import PostInstaFeedSelect from '@pages/Post/PostInstaFeedSelect'; import Chats from '@pages/Chats'; import ChatRoom from '@pages/Chats/ChatRoom'; +import MatchingRoom from '@pages/Chats/MatchingRoom'; import NotFound from '@pages/NotFound'; +import { getUserInfoApi } from '@apis/user'; +import { getCurrentUserId } from '@utils/getCurrentUserId'; +import Loading from '@components/Loading'; const ProtectedRoute = ({ children }: { children: JSX.Element }) => { - const isAuthenticated = Boolean(localStorage.getItem('new_jwt_token')); + const [isAuthenticated, setIsAuthenticated] = useState(null); + + useEffect(() => { + const checkAuth = async () => { + const currentUserId = getCurrentUserId(); + const response = await getUserInfoApi(currentUserId); + setIsAuthenticated(response.isSuccess); + }; + checkAuth(); + }, []); + + if (isAuthenticated === null) { + return ; + } + return isAuthenticated ? children : ; }; @@ -55,6 +74,7 @@ const protectedRoutes = [ // 메시지/채팅 { path: '/chats', element: }, { path: '/chats/:chatRoomId', element: }, + { path: '/matching', element: }, ]; // 인증이 필요 없는 페이지 배열 @@ -64,6 +84,7 @@ const publicRoutes = [ { path: '/signup', element: }, { path: '/signup/terms-agreement', element: }, + { path: '/signup/pick-my-style', element: }, ]; const App: React.FC = () => { diff --git a/src/apis/auth/dto.ts b/src/apis/auth/dto.ts index e0490291..1c7d361d 100644 --- a/src/apis/auth/dto.ts +++ b/src/apis/auth/dto.ts @@ -12,4 +12,5 @@ export interface getUserInfoByJwtData { profilePictureUrl: string; bio: string; birthDate: string; + userStyletags: string[]; } diff --git a/src/apis/chatting/dto.ts b/src/apis/chatting/dto.ts index 2d317a07..12181c15 100644 --- a/src/apis/chatting/dto.ts +++ b/src/apis/chatting/dto.ts @@ -21,7 +21,7 @@ export interface LatestMessageDto { // 채팅방 전체 대화 내역 조회 // 최근 메시지 수신 // response -export interface chatRoomMessagesData { +export interface ChatRoomMessagesData { id: number; content: string; fromUser: FromUserDto; diff --git a/src/apis/matching/dto.ts b/src/apis/matching/dto.ts index ccae2fc6..99fdff8e 100644 --- a/src/apis/matching/dto.ts +++ b/src/apis/matching/dto.ts @@ -1,4 +1,4 @@ -import type { BaseSuccessResponse } from '@apis/core/dto'; +type RequestStatusEnum = 'accepted' | 'rejected' | 'pending'; // 매칭 요청 // request @@ -8,39 +8,34 @@ export interface CreateMatchingRequest { message: string; } -// response -export type CreateMatchingResponse = BaseSuccessResponse; +// 최근 매칭 조회 (채팅방 리스트에서) +export interface LatestMatchingData { + id?: number; + requesterId?: number; + targetId?: number; + requestStatus?: RequestStatusEnum; + createdAt: Date; +} -export interface CreateMatchingData { - id: number; // matchingId +// 전체 매칭 리스트 조회 +export interface MatchingData { + id: number; + message: string; + createdAt: string; chatRoomId: number; - requesterId: number; targetId: number; -} - -// 매칭 리스트 조회 -// response -export type GetMatchingListResponse = BaseSuccessResponse; - -export interface GetMatchingListData { - hasMatching: boolean; - matchingsCount: number; - matching: MatchingDto[]; -} - -export interface MatchingDto { - id: number; // matchingId requester: RequesterDto; + requestStatus: RequestStatusEnum; } export interface RequesterDto { - id: number; // requesterId + id: number; nickname: string; profilePictureUrl: string; - representativePost: RepresentativePost; + representativePost: RepresentativePostDto; } -export interface RepresentativePost { +export interface RepresentativePostDto { postImages: PostImageDto[]; styleTags: string[]; } @@ -49,20 +44,3 @@ export interface PostImageDto { url: string; orderNum: number; } - -// 매칭 요청 수락 및 거절 -// request -export interface ModifyMatchingStatusRequest { - requestStatus: 'accept' | 'reject'; -} - -// response -export type ModifyMatchingStatusResponse = BaseSuccessResponse; - -export interface ModifyMatchingStatusData { - id: number; // matchingId - requesterId: number; - targetId: number; - requestStatus: string; - chatRoomId: number; -} diff --git a/src/apis/matching/index.ts b/src/apis/matching/index.ts deleted file mode 100644 index cb0c62ca..00000000 --- a/src/apis/matching/index.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { newRequest } from '@apis/core'; - -import type { - CreateMatchingRequest, - CreateMatchingResponse, - GetMatchingListResponse, - ModifyMatchingStatusRequest, - ModifyMatchingStatusResponse, -} from './dto'; - -// 매칭 생성 -export const createMatchingApi = (data: CreateMatchingRequest) => - newRequest.post('/matching', data); - -// 매칭 리스트 조회 -export const getMatchingListApi = () => newRequest.get('/matching'); - -// 매칭 요청 수락 및 거절 -export const modifyMatchingStatusApi = (matchingId: number, data: ModifyMatchingStatusRequest) => - newRequest.patch(`/matching/${matchingId}`, data); diff --git a/src/apis/user/dto.ts b/src/apis/user/dto.ts index 0cc1d5ff..3a6b8109 100644 --- a/src/apis/user/dto.ts +++ b/src/apis/user/dto.ts @@ -11,6 +11,7 @@ export interface UserInfoData { bio: string; birthDate: string; isFriend: boolean; + userStyletags: string[]; } // 사용자 정보 조회 응답 @@ -28,6 +29,7 @@ export interface PatchUserInfoRequest { nickname: string; profilePictureUrl: string; bio: string; + userStyletags: string[]; } // 회원 탈퇴 응답 diff --git a/src/assets/default/share.svg b/src/assets/default/share.svg new file mode 100644 index 00000000..68778689 --- /dev/null +++ b/src/assets/default/share.svg @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/src/context/SocketProvider.tsx b/src/context/SocketProvider.tsx index a77d7108..e7b593f0 100644 --- a/src/context/SocketProvider.tsx +++ b/src/context/SocketProvider.tsx @@ -2,47 +2,55 @@ import { createContext, useContext, useEffect, useState } from 'react'; import { io, Socket } from 'socket.io-client'; -const SocketContext = createContext(null); +type SocketMap = { [endpoint: string]: Socket }; + +const SocketContext = createContext(null); export const SocketProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { - const [socket, setSocket] = useState(null); + const [socketMap, setSocketMap] = useState({}); useEffect(() => { - const newSocket = io(`${import.meta.env.VITE_NEW_API_URL}/socket/chatting`, { - transports: ['websocket'], - }); - setSocket(newSocket); - - newSocket.on('connect', () => { - console.log('connection is open'); + const endpoints = ['chatting', 'matching']; // 필요한 엔드포인트 추가 + const newSockets: SocketMap = {}; + + endpoints.forEach((endpoint) => { + const socket = io(`${import.meta.env.VITE_NEW_API_URL}/socket/${endpoint}`, { + transports: ['websocket'], + }); + newSockets[endpoint] = socket; + + socket.on('connect', () => { + console.log(`${endpoint} connection is open`); + }); + + socket.on('disconnect', (reason) => { + console.log(`${endpoint} Disconnected from server:`, reason); + }); + + socket.on('connect_error', (err) => { + console.log(`${endpoint} connect error:`, err.message); + }); }); - newSocket.on('disconnect', (reason) => { - console.log('Disconnected from server:', reason); - }); - - newSocket.on('connect_error', (err) => { - console.log(err.message); - }); + setSocketMap(newSockets); return () => { - newSocket.disconnect(); + Object.values(newSockets).forEach((socket) => socket.disconnect()); }; }, []); - // 소켓 설정이 완료되지 않은 경우 렌더링 방지 - // 채팅방에서 새로고침했을 때 오류 방지 - if (!socket) { + if (!Object.keys(socketMap).length) { return null; } - return {children}; + return {children}; }; -export const useSocket = () => { - const context = useContext(SocketContext); - if (context === null) { - throw new Error('useSocket must be used within a SocketProvider'); +// 엔드포인트를 인자로 받아 해당 소켓을 반환하는 훅 +export const useSocket = (endpoint = 'chatting') => { + const socketMap = useContext(SocketContext); + if (!socketMap || !socketMap[endpoint]) { + throw new Error(`useSocket must be used within a SocketProvider with a valid endpoint (${endpoint})`); } - return context; + return socketMap[endpoint]; }; diff --git a/src/pages/Account/AccountCancel/styles.tsx b/src/pages/Account/AccountCancel/styles.tsx index e528432a..cb515c44 100644 --- a/src/pages/Account/AccountCancel/styles.tsx +++ b/src/pages/Account/AccountCancel/styles.tsx @@ -16,30 +16,30 @@ export const SubTitle = styled.h3` margin-bottom: 0.625rem; text-align: center; text-align: left; - margin-top: 10px; + margin-top: 0.625rem; padding: 1.25rem; `; export const Text = styled.p` font-size: 0.875rem; - margin-bottom: 5px; + margin-bottom: 0.3125rem; text-align: left; - margin-top: 10px; + margin-top: 0.625rem; padding: 0rem 1.25rem; `; export const InfoBox = styled.div` background: ${({ theme }) => theme.colors.background.secondary}; - padding: 70px; - margin-top: 10px; - border-radius: 10px; - margin: 10px 20px 1.25rem 20px; + padding: 4.375rem; + margin-top: 0.625rem; + border-radius: 0.625rem; + margin: 0.625rem 1.25rem 1.25rem 1.25rem; `; export const InfoItem = styled.p` font-size: 0.875rem; margin-bottom: 0.625rem; - padding: 2px 10px; + padding: 0.125rem 0.625rem; display: flex; justify-content: center; align-items: center; @@ -51,7 +51,7 @@ export const CheckboxWrapper = styled.div` display: flex; align-items: center; margin-bottom: 1.25rem; - padding: 0rem 15px; + padding: 0rem 0.9375rem; input[type='checkbox'] { margin-right: 0.625rem; diff --git a/src/pages/Account/AccountEdit/styles.tsx b/src/pages/Account/AccountEdit/styles.tsx index 65eb681d..743e12cd 100644 --- a/src/pages/Account/AccountEdit/styles.tsx +++ b/src/pages/Account/AccountEdit/styles.tsx @@ -1,7 +1,7 @@ import { styled } from 'styled-components'; export const ProfileEditContainer = styled.div` - max-width: 512px; + max-width: 32rem; display: flex; flex-direction: column; position: relative; @@ -11,7 +11,7 @@ export const Section = styled.div` margin-top: 1.875rem; margin-bottom: 1.875rem; width: 100%; - padding: 0px 30px; + padding: 0rem 1.875rem; `; export const SectionTitle = styled.div` @@ -62,7 +62,7 @@ export const SnsConnection = styled.div` export const MemberInfo = styled.div` display: flex; flex-direction: column; - margin-top: 35px; + margin-top: 2.1875rem; width: 100%; `; @@ -71,7 +71,7 @@ export const MemberInfoRow = styled.div` align-items: center; justify-content: flex-start; margin-bottom: 0.625rem; - margin-top: 10px; + margin-top: 0.625rem; `; export const Label = styled.div` diff --git a/src/pages/Account/AccountSetting/index.tsx b/src/pages/Account/AccountSetting/index.tsx index 75f1bcbc..65ba1624 100644 --- a/src/pages/Account/AccountSetting/index.tsx +++ b/src/pages/Account/AccountSetting/index.tsx @@ -11,9 +11,9 @@ import imageBasic from '@assets/default/defaultProfile.svg'; import leave from '@assets/default/leave.svg'; import Profile_s from '@assets/default/my-page.svg'; -import ConfirmationModal from '@components/ConfirmationModal/index'; import { OODDFrame } from '@components/Frame/Frame'; import Loading from '@components/Loading/index'; +import Modal from '@components/Modal'; import { StyledText } from '@components/Text/StyledText'; import TopBar from '@components/TopBar/index'; @@ -111,11 +111,11 @@ const AccountSetting: React.FC = () => { {isLogoutModalOpen && ( - )} diff --git a/src/pages/Account/AccountSetting/styles.tsx b/src/pages/Account/AccountSetting/styles.tsx index 5b1ce005..ef19ecf5 100644 --- a/src/pages/Account/AccountSetting/styles.tsx +++ b/src/pages/Account/AccountSetting/styles.tsx @@ -14,7 +14,7 @@ export const ProfilePicWrapper = styled.div` flex-direction: column; align-items: center; margin-bottom: 1.25rem; - margin-top: 24px; + margin-top: 1.5rem; `; export const ProfilePic = styled.div` @@ -42,7 +42,7 @@ export const Row = styled.div` justify-content: center; align-items: center; width: 100%; - margin-bottom: 10px; + margin-bottom: 0.625rem; ${Label} { width: auto; @@ -59,15 +59,15 @@ export const List = styled.ul` padding: 0; margin: 0; list-style: none; - border-top: 0px solid ${({ theme }) => theme.colors.background.divider}; + border-top: 0rem solid ${({ theme }) => theme.colors.background.divider}; position: absolute; - bottom: 20px; + bottom: 1.25rem; `; export const ListItem = styled.li` display: flex; align-items: center; - padding: 15px 1.25rem; + padding: 0.9375rem 1.25rem; border-bottom: 0px solid ${({ theme }) => theme.colors.background.divider}; cursor: pointer; diff --git a/src/pages/Chats/ChatRoom/ChatBox/index.tsx b/src/pages/Chats/ChatBox/index.tsx similarity index 93% rename from src/pages/Chats/ChatRoom/ChatBox/index.tsx rename to src/pages/Chats/ChatBox/index.tsx index 8931cc03..1661a2eb 100644 --- a/src/pages/Chats/ChatRoom/ChatBox/index.tsx +++ b/src/pages/Chats/ChatBox/index.tsx @@ -11,7 +11,7 @@ import SendIcon from '@assets/default/send-message.svg'; import { ChatBoxContainer, Textarea, SendButton } from './styles'; -const ChatBox: React.FC = () => { +const ChatBox: React.FC<{ disabled?: boolean }> = ({ disabled = false }) => { const [newMessage, setNewMessage] = useState(''); const textareaRef = useRef(null); const socket = useSocket(); @@ -22,9 +22,9 @@ const ChatBox: React.FC = () => { const isOtherUserValid = !!(otherUser && otherUser.id); useEffect(() => { - if (textareaRef.current && !isOtherUserValid) { + if (textareaRef.current && (!isOtherUserValid || disabled)) { textareaRef.current.disabled = true; - textareaRef.current.placeholder = '메시지를 보낼 수 없습니다.'; + textareaRef.current.placeholder = '메시지를 보낼 수 없는 채팅방입니다.'; } }, []); diff --git a/src/pages/Chats/ChatRoom/ChatBox/styles.tsx b/src/pages/Chats/ChatBox/styles.tsx similarity index 100% rename from src/pages/Chats/ChatRoom/ChatBox/styles.tsx rename to src/pages/Chats/ChatBox/styles.tsx diff --git a/src/pages/Chats/ChatRoom/createExtendedMessages.ts b/src/pages/Chats/ChatRoom/createExtendedMessages.ts index bf9747e5..0b01eccf 100644 --- a/src/pages/Chats/ChatRoom/createExtendedMessages.ts +++ b/src/pages/Chats/ChatRoom/createExtendedMessages.ts @@ -3,12 +3,15 @@ import 'dayjs/locale/ko'; import defaultProfile from '@assets/default/defaultProfile.svg'; -import type { OtherUserDto, chatRoomMessagesData } from '@apis/chatting/dto'; +import type { OtherUserDto, ChatRoomMessagesData } from '@apis/chatting/dto'; -import type { ExtendedMessageDto, RcvdMessageProps, SentMessageProps } from './dto'; +import { RcvdMessageProps } from '../RcvdMessage/dto'; +import { SentMessageProps } from '../SentMessage/dto'; + +import type { ExtendedMessageDto } from './dto'; export const createExtendedMessages = ( - allMessages: chatRoomMessagesData[], + allMessages: ChatRoomMessagesData[], userId: number, otherUser: OtherUserDto | null, ) => { @@ -21,7 +24,7 @@ export const createExtendedMessages = ( }; // 렌더링에 필요한 요소를 추가한 메시지 배열 - const temp: ExtendedMessageDto[] = allMessages.map((message: chatRoomMessagesData, index) => { + const temp: ExtendedMessageDto[] = allMessages.map((message: ChatRoomMessagesData, index) => { const prevMessage = index !== 0 ? allMessages[index - 1] : null; const nextMessage = index !== allMessages.length - 1 ? allMessages[index + 1] : null; const formattedTime = dayjs(message.createdAt).format('HH:mm'); diff --git a/src/pages/Chats/ChatRoom/dto.ts b/src/pages/Chats/ChatRoom/dto.ts index dd409693..6c821d6d 100644 --- a/src/pages/Chats/ChatRoom/dto.ts +++ b/src/pages/Chats/ChatRoom/dto.ts @@ -1,24 +1,10 @@ -import type { chatRoomMessagesData } from '@apis/chatting/dto'; +import type { ChatRoomMessagesData } from '@apis/chatting/dto'; -export interface ExtendedMessageDto extends chatRoomMessagesData { +import { RcvdMessageProps } from '../RcvdMessage/dto'; +import { SentMessageProps } from '../SentMessage/dto'; + +export interface ExtendedMessageDto extends ChatRoomMessagesData { isDateBarVisible: boolean; sentMessage?: SentMessageProps; rcvdMessage?: RcvdMessageProps; } - -export interface SentMessageProps { - content: string; - isSenderChanged: boolean; // 상단 마진 추가 여부 - isTimeVisible: boolean; // 메시지 옆 시간 표시 여부 - formattedTime: string; // 타임스탬프를 HH:MM 형태로 변환한 값 -} - -export interface RcvdMessageProps { - fromUserNickname: string; - profilePictureUrl: string; - content: string; - isSenderChanged: boolean; // 상단 마진 추가 여부 - isProfileImageVisible: boolean; // 사용자 프로필 표시 여부 - isTimeVisible: boolean; // 메시지 옆 시간 표시 여부 - formattedTime: string; // 타임스탬프를 HH:MM 형태로 변환한 값 -} diff --git a/src/pages/Chats/ChatRoom/index.tsx b/src/pages/Chats/ChatRoom/index.tsx index 21899ab3..844ef6d3 100644 --- a/src/pages/Chats/ChatRoom/index.tsx +++ b/src/pages/Chats/ChatRoom/index.tsx @@ -24,7 +24,7 @@ import Loading from '@components/Loading'; import Modal from '@components/Modal'; import TopBar from '@components/TopBar'; -import type { chatRoomMessagesData } from '@apis/chatting/dto'; +import type { ChatRoomMessagesData } from '@apis/chatting/dto'; import type { PostUserBlockRequest } from '@apis/user-block/dto'; import type { BottomSheetMenuProps } from '@components/BottomSheet/BottomSheetMenu/dto'; import type { BottomSheetProps } from '@components/BottomSheet/dto'; @@ -32,10 +32,10 @@ import type { ModalProps } from '@components/Modal/dto'; import type { ExtendedMessageDto } from './dto'; -import ChatBox from './ChatBox/index'; -import DateBar from './DateBar/index'; -import RcvdMessage from './RcvdMessage/index'; -import SentMessage from './SentMessage/index'; +import ChatBox from '../ChatBox/index'; +import DateBar from '../DateBar/index'; +import RcvdMessage from '../RcvdMessage/index'; +import SentMessage from '../SentMessage/index'; import { createExtendedMessages } from './createExtendedMessages'; import { MessagesContainer } from './styles'; @@ -114,7 +114,7 @@ const ChatRoom: React.FC = () => { }; // 전체 메시지 조회 socket api - const getChatRoomMessages = (data: chatRoomMessagesData[]) => { + const getChatRoomMessages = (data: ChatRoomMessagesData[]) => { setAllMessages(data); if (data.length > messageLengthRef.current) { setIsScroll((prev) => !prev); @@ -123,7 +123,7 @@ const ChatRoom: React.FC = () => { }; // 새 메시지 수신 socket api - const getNewMessage = (data: chatRoomMessagesData) => { + const getNewMessage = (data: ChatRoomMessagesData) => { setAllMessages((prevMessages) => [...prevMessages, data]); setIsScroll((prev) => !prev); }; diff --git a/src/pages/Chats/ChatRoom/DateBar/index.tsx b/src/pages/Chats/DateBar/index.tsx similarity index 100% rename from src/pages/Chats/ChatRoom/DateBar/index.tsx rename to src/pages/Chats/DateBar/index.tsx diff --git a/src/pages/Chats/ChatRoom/DateBar/styles.tsx b/src/pages/Chats/DateBar/styles.tsx similarity index 100% rename from src/pages/Chats/ChatRoom/DateBar/styles.tsx rename to src/pages/Chats/DateBar/styles.tsx diff --git a/src/pages/Chats/Matching/Cards/Card/dto.ts b/src/pages/Chats/Matching/Cards/Card/dto.ts deleted file mode 100644 index 3fda591d..00000000 --- a/src/pages/Chats/Matching/Cards/Card/dto.ts +++ /dev/null @@ -1,6 +0,0 @@ -import type { MatchingDto } from '@apis/matching/dto'; - -export interface CardProps { - removeRejectedMatching: () => void; - matching: MatchingDto; -} diff --git a/src/pages/Chats/Matching/Cards/Card/index.tsx b/src/pages/Chats/Matching/Cards/Card/index.tsx deleted file mode 100644 index c0ec4802..00000000 --- a/src/pages/Chats/Matching/Cards/Card/index.tsx +++ /dev/null @@ -1,160 +0,0 @@ -import { useState } from 'react'; -import { useNavigate } from 'react-router-dom'; - -import { useRecoilState } from 'recoil'; -import { Pagination } from 'swiper/modules'; -import { Swiper, SwiperSlide } from 'swiper/react'; -import 'swiper/css'; -import 'swiper/css/pagination'; - -import theme from '@styles/theme'; - -import { modifyMatchingStatusApi } from '@apis/matching'; -import { handleError } from '@apis/util/handleError'; -import { OtherUserAtom } from '@recoil/util/OtherUser'; - -import acceptButton from '@assets/default/accept.svg'; -import defaultProfile from '@assets/default/defaultProfile.svg'; -import rejectButton from '@assets/default/reject.svg'; - -import Modal from '@components/Modal'; -import { StyledText } from '@components/Text/StyledText'; - -import type { ModalProps } from '@components/Modal/dto'; - -import type { CardProps } from './dto'; - -import { - ArrowButton, - Btn, - CardLayout, - OOTDImgBackground, - OOTDImgBox, - ProfileContainer, - ProfileImgBox, - ProfileInfo, - Reaction, - SeeMore, -} from './styles'; - -const Card: React.FC = ({ removeRejectedMatching, matching }) => { - const [isStatusModalOpen, setIsStatusModalOpen] = useState(false); - const [modalContent, setModalContent] = useState('알 수 없는 오류가 발생했습니다.\n관리자에게 문의해 주세요.'); - const [, setOtherUser] = useRecoilState(OtherUserAtom); - const nav = useNavigate(); - const requester = matching.requester; - - const handleUserClick = () => { - nav(`/profile/${requester.id}`); - }; - - const handleRejectButtonClick = () => { - modifyMatchingStatus('reject'); - }; - - const handleAcceptButtonClick = () => { - modifyMatchingStatus('accept'); - }; - - // 매칭 거절 및 수락 api - const modifyMatchingStatus = async (status: 'accept' | 'reject') => { - try { - console.log(matching); - const response = await modifyMatchingStatusApi(matching.id, { requestStatus: status }); - - if (response.isSuccess) { - removeRejectedMatching(); // 매칭 리스트에서 해당 매칭을 제거 - - if (status === 'accept') { - setOtherUser({ - id: requester.id, - nickname: requester.nickname, - profilePictureUrl: requester.profilePictureUrl, - }); - nav(`/chats/${response.data.chatRoomId}`); - } - } - } catch (error) { - const errorMessage = handleError(error); - setModalContent(errorMessage); - setIsStatusModalOpen(true); - } - }; - - const statusModalProps: ModalProps = { - content: modalContent, - onClose: () => { - setIsStatusModalOpen(false); - }, - }; - - return ( - - - - profile - - - - {requester.nickname || '알수없음'} - -
- {requester.representativePost.styleTags.map((tag, index) => ( -
- - {tag} - - {index < requester.representativePost.styleTags.length - 1 && ( - - ,  - - )} -
- ))} -
-
- nav(`/profile/${requester.id}`)}> - - OOTD 더 보기 - - - -
- - - {requester.representativePost.postImages.map((postImage) => ( - - OOTD -
- -
- ))} -
- - - reject - - - accept - - -
- {isStatusModalOpen && } -
- ); -}; - -export default Card; diff --git a/src/pages/Chats/Matching/Cards/dto.ts b/src/pages/Chats/Matching/Cards/dto.ts deleted file mode 100644 index d097ff89..00000000 --- a/src/pages/Chats/Matching/Cards/dto.ts +++ /dev/null @@ -1,4 +0,0 @@ -export interface CardsProps { - // 탭바의 matchingCount와 연동하여 매칭 요청이 없으면 요청 탭 비활성화 - decreaseMatchingCount: () => void; -} diff --git a/src/pages/Chats/Matching/Cards/index.tsx b/src/pages/Chats/Matching/Cards/index.tsx deleted file mode 100644 index a5648bec..00000000 --- a/src/pages/Chats/Matching/Cards/index.tsx +++ /dev/null @@ -1,68 +0,0 @@ -import { useEffect, useRef, useState } from 'react'; - -import { Pagination } from 'swiper/modules'; -import { Swiper, SwiperRef, SwiperSlide } from 'swiper/react'; - -import 'swiper/css'; -import 'swiper/css/pagination'; -import { getMatchingListApi } from '@apis/matching'; - -import type { MatchingDto } from '@apis/matching/dto'; - -import type { CardsProps } from './dto'; - -import Card from './Card/index'; - -import { CardsContainer } from './styles'; - -const Cards: React.FC = ({ decreaseMatchingCount }) => { - const [matchings, setMatchings] = useState([]); - const swiperRef = useRef(null); - - // 매칭 요청 거절 시 거절한 요청을 제거하는 함수 - const removeRejectedMatching = (index: number) => { - if (swiperRef.current && swiperRef.current.swiper) { - // 해당 요청을 리스트에서 제거 - const remainMatchings = matchings.filter((_, i) => i !== index); - setMatchings(remainMatchings); - decreaseMatchingCount(); - } else { - console.log('Swiper instance is not available'); - } - }; - - // 매칭 리스트 조회 api - const getMatchingList = async () => { - const response = await getMatchingListApi(); - - setMatchings(response.data.matching); - }; - - useEffect(() => { - getMatchingList(); - }, []); - - return ( - - - {matchings.map((matching, index) => ( - - removeRejectedMatching(index)} - /> - - ))} - - - ); -}; - -export default Cards; diff --git a/src/pages/Chats/Matching/Cards/styles.tsx b/src/pages/Chats/Matching/Cards/styles.tsx deleted file mode 100644 index 7b4fe6c7..00000000 --- a/src/pages/Chats/Matching/Cards/styles.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import { styled } from 'styled-components'; - -export const CardsContainer = styled.div` - display: flex; - flex-direction: column; - - .parentSwiper { - width: 100%; - z-index: 10; - } - - .parentSwiper .swiper-slide { - transition: transform 0.3s; - transform: scale(0.95); - } - - .parentSwiper .swiper-slide-active { - transform: scale(1); - } - - .parentSwiper .swiper-slide-next, - .parentSwiper .swiper-slide-prev { - transform: scale(0.95); - } - - .parentSwiper.swiper-container { - margin-left: 0.9375rem; - } -`; diff --git a/src/pages/Chats/Matching/dto.ts b/src/pages/Chats/Matching/dto.ts deleted file mode 100644 index 77f292c7..00000000 --- a/src/pages/Chats/Matching/dto.ts +++ /dev/null @@ -1,4 +0,0 @@ -export interface MatchingProps { - matchingCount: number; - decreaseMatchingCount: () => void; -} diff --git a/src/pages/Chats/Matching/index.tsx b/src/pages/Chats/Matching/index.tsx deleted file mode 100644 index b3822165..00000000 --- a/src/pages/Chats/Matching/index.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import { memo } from 'react'; - -import theme from '@styles/theme'; - -import { StyledText } from '@components/Text/StyledText'; - -import type { MatchingProps } from './dto'; - -import Cards from './Cards/index'; - -import { ReqeustInfo } from './styles'; - -const Matching: React.FC = ({ matchingCount, decreaseMatchingCount }) => { - return ( - <> - - Message  - - {matchingCount} - - - - - ); -}; - -export default memo(Matching); diff --git a/src/pages/Chats/Matching/styles.tsx b/src/pages/Chats/Matching/styles.tsx deleted file mode 100644 index 57375bbf..00000000 --- a/src/pages/Chats/Matching/styles.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import { styled } from 'styled-components'; - -import { StyledText } from '@components/Text/StyledText'; - -export const ReqeustInfo = styled(StyledText)` - display: flex; - align-items: center; - padding: 0.5rem 1.25rem; - text-align: left; -`; diff --git a/src/pages/Chats/MatchingRoom/Card/dto.ts b/src/pages/Chats/MatchingRoom/Card/dto.ts new file mode 100644 index 00000000..a3ff3c62 --- /dev/null +++ b/src/pages/Chats/MatchingRoom/Card/dto.ts @@ -0,0 +1,8 @@ +import type { MatchingData } from '@apis/matching/dto'; + +// export interface CardProps { +// removeRejectedMatching: () => void; +// matching: MatchingData; +// } + +export type CardProps = Pick; diff --git a/src/pages/Chats/MatchingRoom/Card/index.tsx b/src/pages/Chats/MatchingRoom/Card/index.tsx new file mode 100644 index 00000000..2cf8c5ec --- /dev/null +++ b/src/pages/Chats/MatchingRoom/Card/index.tsx @@ -0,0 +1,79 @@ +import { useNavigate } from 'react-router-dom'; + +import { Pagination } from 'swiper/modules'; +import { Swiper, SwiperSlide } from 'swiper/react'; +import 'swiper/css'; +import 'swiper/css/pagination'; + +import theme from '@styles/theme'; + +import defaultProfile from '@assets/default/defaultProfile.svg'; + +import { StyledText } from '@components/Text/StyledText'; + +import type { CardProps } from './dto'; + +import { CardLayout, OOTDImgBackground, OOTDImgBox, ProfileContainer, ProfileImgBox, ProfileInfo } from './styles'; + +const Card: React.FC = ({ requester }) => { + const nav = useNavigate(); + + const handleUserClick = () => { + nav(`/profile/${requester.id}`); + }; + + return ( + + + + profile + + + + {requester.nickname || '알수없음'} + +
+ {requester.representativePost.styleTags.map((tag, index) => ( +
+ + {tag} + + {index < requester.representativePost.styleTags.length - 1 && ( + + ,  + + )} +
+ ))} +
+
+
+ + + {requester.representativePost.postImages.map((postImage) => ( + + OOTD +
+ +
+ ))} +
+
+
+ ); +}; + +export default Card; diff --git a/src/pages/Chats/Matching/Cards/Card/styles.tsx b/src/pages/Chats/MatchingRoom/Card/styles.tsx similarity index 69% rename from src/pages/Chats/Matching/Cards/Card/styles.tsx rename to src/pages/Chats/MatchingRoom/Card/styles.tsx index 3d345859..57fd1b78 100644 --- a/src/pages/Chats/Matching/Cards/Card/styles.tsx +++ b/src/pages/Chats/MatchingRoom/Card/styles.tsx @@ -1,24 +1,21 @@ import { styled } from 'styled-components'; -import ArrowIcon from '@assets/arrow/min-right.svg'; - export const CardLayout = styled.div` - background-color: ${({ theme }) => theme.colors.background.divider}; border-radius: 0.5rem; position: relative; - height: 100%; + height: fit-content; `; export const ProfileContainer = styled.div` display: grid; grid-template-columns: auto 1fr auto; align-items: center; - padding: 1rem 0.5rem; + padding: 0.5rem 0; `; export const ProfileImgBox = styled.div` - width: 3.25rem; - height: 3.25rem; + width: 2.25rem; + height: 2.25rem; margin-right: 0.5rem; border-radius: 50%; cursor: pointer; @@ -45,35 +42,19 @@ export const ProfileInfo = styled.div` } `; -export const SeeMore = styled.div` - cursor: pointer; - display: flex; - align-items: center; - margin-bottom: 2.13rem; -`; - -export const ArrowButton = styled.button` - width: 1.125rem; - height: 1.125rem; - background-image: url(${ArrowIcon}); - background-repeat: no-repeat; - background-position: center; -`; - export const OOTDImgBox = styled.div` position: relative; width: 100%; - height: 100%; - border-radius: 0 0 0.5rem 0.5rem; + height: fit-content; + margin-bottom: 0.5rem; + border-radius: 0.5rem; overflow: hidden; display: flex; justify-content: center; align-items: center; - aspect-ratio: 1/1; .slide-image-small { width: 100%; - max-width: 640px; height: 100%; object-fit: contain; } @@ -136,24 +117,3 @@ export const OOTDImgBackground = styled.div<{ $src: string }>` background-repeat: no-repeat; background-size: cover; `; - -export const Reaction = styled.div` - position: absolute; - bottom: 0; - padding: 1rem 0rem; - display: flex; - align-items: center; - gap: 0.9375rem; - z-index: 100; -`; - -export const Btn = styled.button` - cursor: pointer; - width: 3.5rem; - height: 3.5rem; - background-color: transparent; - - display: flex; - justify-content: center; - align-items: center; -`; diff --git a/src/pages/Chats/MatchingRoom/MatchingMessage/index.tsx b/src/pages/Chats/MatchingRoom/MatchingMessage/index.tsx new file mode 100644 index 00000000..5abbb4e7 --- /dev/null +++ b/src/pages/Chats/MatchingRoom/MatchingMessage/index.tsx @@ -0,0 +1,55 @@ +import dayjs from 'dayjs'; + +import RcvdMessage from '@pages/Chats/RcvdMessage'; + +import defaultProfile from '@assets/default/defaultProfile.svg'; + +import type { MatchingData } from '@apis/matching/dto'; +import type { RcvdMessageProps } from '@pages/Chats/RcvdMessage/dto'; + +import type { CardProps } from '../Card/dto'; + +import Card from '../Card'; + +const MatchingMessage: React.FC = ({ id, message, createdAt, chatRoomId, requester }: MatchingData) => { + const formattedTime = dayjs(createdAt).format('HH:mm'); + + const firstMessageProps: RcvdMessageProps = { + fromUserNickname: '오딩이', + profilePictureUrl: defaultProfile, + content: '얘가 너 소개받고 싶대', + isSenderChanged: false, + isProfileImageVisible: true, + isTimeVisible: false, + formattedTime, + }; + + const matchingMessageProps: RcvdMessageProps = { + fromUserNickname: '오딩이', + profilePictureUrl: defaultProfile, + content: message, + isSenderChanged: false, + isProfileImageVisible: false, + isTimeVisible: false, + formattedTime, + }; + + const cardProps: CardProps = { + id, + chatRoomId, + requester, + }; + + return ( + <> + +
+ + + +
+ + ); +}; + +export default MatchingMessage; diff --git a/src/pages/Chats/MatchingRoom/NoMatchingMessage/index.tsx b/src/pages/Chats/MatchingRoom/NoMatchingMessage/index.tsx new file mode 100644 index 00000000..aa8b384a --- /dev/null +++ b/src/pages/Chats/MatchingRoom/NoMatchingMessage/index.tsx @@ -0,0 +1,25 @@ +import dayjs from 'dayjs'; + +import RcvdMessage from '@pages/Chats/RcvdMessage'; + +import defaultProfile from '@assets/default/defaultProfile.svg'; + +import type { RcvdMessageProps } from '@pages/Chats/RcvdMessage/dto'; + +const NoMatchingMessage: React.FC = () => { + const formattedTime = dayjs(new Date()).format('HH:mm'); + + const messageProps: RcvdMessageProps = { + fromUserNickname: '오딩이', + profilePictureUrl: defaultProfile, + content: '매칭이 들어오면 오딩이가 알려줄게!', + isSenderChanged: true, + isProfileImageVisible: true, + isTimeVisible: false, + formattedTime, + }; + + return ; +}; + +export default NoMatchingMessage; diff --git a/src/pages/Chats/MatchingRoom/ResponseMessage/index.tsx b/src/pages/Chats/MatchingRoom/ResponseMessage/index.tsx new file mode 100644 index 00000000..f6ce1059 --- /dev/null +++ b/src/pages/Chats/MatchingRoom/ResponseMessage/index.tsx @@ -0,0 +1,59 @@ +import { useNavigate } from 'react-router-dom'; + +import { useRecoilState } from 'recoil'; + +import theme from '@styles/theme'; + +import { RequesterDto } from '@apis/matching/dto'; +import { useSocket } from '@context/SocketProvider'; +import { OtherUserAtom } from '@recoil/util/OtherUser'; + +import { StyledText } from '@components/Text/StyledText'; + +import { ResponseButton, ResponseContainer } from './styles'; + +export interface ResponseMessageProps { + matchingId: number; + chatRoomId: number; + requester: Omit; + requestStatus: 'accepted' | 'rejected' | 'pending'; +} + +const ResponseMessage: React.FC = ({ matchingId, chatRoomId, requester, requestStatus }) => { + const socket = useSocket('matching'); + const isPending = requestStatus === 'pending'; + const nav = useNavigate(); + const [, setOtherUser] = useRecoilState(OtherUserAtom); + + const handlebuttonClick = (status: 'accept' | 'reject') => { + if (requestStatus !== 'pending') return; + if (socket) { + socket.emit('patchMatching', { id: matchingId, requestStatus: status }); + if (status === 'accept') { + setOtherUser(requester); + nav(`/chats/${chatRoomId}`); + } + } + }; + + return ( + + {(requestStatus === 'pending' || requestStatus === 'rejected') && ( + handlebuttonClick('reject')}> + + 거절 + + + )} + {(requestStatus === 'pending' || requestStatus === 'accepted') && ( + handlebuttonClick('accept')}> + + 수락 + + + )} + + ); +}; + +export default ResponseMessage; diff --git a/src/pages/Chats/MatchingRoom/ResponseMessage/styles.tsx b/src/pages/Chats/MatchingRoom/ResponseMessage/styles.tsx new file mode 100644 index 00000000..22742b70 --- /dev/null +++ b/src/pages/Chats/MatchingRoom/ResponseMessage/styles.tsx @@ -0,0 +1,16 @@ +import { styled } from 'styled-components'; + +export const ResponseContainer = styled.div` + display: flex; + gap: 0.5rem; + justify-content: flex-end; +`; + +export const ResponseButton = styled.button<{ $isPending: boolean }>` + cursor: ${({ $isPending }) => `${$isPending ? 'pointer' : 'default'}`}; + padding: 0.4rem 0.8rem; + margin: 0.5rem 0; + background-color: #f2f2f2; + border-radius: 0.8rem; + overflow-wrap: break-word; +`; diff --git a/src/pages/Chats/MatchingRoom/dto.ts b/src/pages/Chats/MatchingRoom/dto.ts new file mode 100644 index 00000000..f45a874d --- /dev/null +++ b/src/pages/Chats/MatchingRoom/dto.ts @@ -0,0 +1,10 @@ +import { MatchingData } from '@apis/matching/dto'; + +import { RcvdMessageProps } from '../RcvdMessage/dto'; +import { SentMessageProps } from '../SentMessage/dto'; + +export interface ExtendedMessageDto extends MatchingData { + isDateBarVisible: boolean; + sentMessage?: SentMessageProps; + rcvdMessage?: RcvdMessageProps; +} diff --git a/src/pages/Chats/MatchingRoom/index.tsx b/src/pages/Chats/MatchingRoom/index.tsx new file mode 100644 index 00000000..0087226f --- /dev/null +++ b/src/pages/Chats/MatchingRoom/index.tsx @@ -0,0 +1,128 @@ +import { memo, useEffect, useRef, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; + +import { MatchingData } from '@apis/matching/dto'; +import { useSocket } from '@context/SocketProvider'; +import { getCurrentUserId } from '@utils/getCurrentUserId'; + +import Back from '@assets/arrow/left.svg'; + +import { OODDFrame } from '@components/Frame/Frame'; +import TopBar from '@components/TopBar'; + +import ChatBox from '../ChatBox'; + +import MatchingMessage from './MatchingMessage'; +import NoMatchingMessage from './NoMatchingMessage'; +import ResponseMessage from './ResponseMessage'; +import { MessagesContainer } from './styles'; + +const MatchingRoom: React.FC = () => { + const [allMatchings, setAllMatchings] = useState([]); + const [hasNewMatching, setHasNewMatching] = useState(true); + + const [isLoading, setIsLoading] = useState(true); + const [isScroll, setIsScroll] = useState(false); + const chatWindowRef = useRef(null); + + const currentUserId = getCurrentUserId(); + const nav = useNavigate(); + const socket = useSocket('matching'); + + // 메시지 수신 시 아래로 스크롤 (스크롤 아래 고정) + const scrollToBottom = (ref: React.RefObject) => { + if (ref.current) ref.current.scrollIntoView(); + }; + + // 채팅방 입장 시 스크롤 아래로 이동 + useEffect(() => { + const messagesContainer = chatWindowRef.current?.parentElement; + + if (messagesContainer) { + messagesContainer.style.scrollBehavior = 'auto'; + messagesContainer.scrollTop = messagesContainer.scrollHeight; + } + }, []); + + // 메시지 수신 시 + useEffect(() => { + // 스크롤 아래로 이동 + if (isScroll) { + scrollToBottom(chatWindowRef); + setIsScroll(false); + } + }, [allMatchings]); + + useEffect(() => { + // 전체 매칭 불러오기 socket api + const getAllMatchings = ({ matching }: { matching: MatchingData[] }) => { + console.log(matching); + setAllMatchings(matching); + setIsScroll(true); + setIsLoading(false); + }; + + const getNewMatching = (data: MatchingData) => { + if (JSON.stringify(data) === '{}') { + setHasNewMatching(false); + } else { + setHasNewMatching(true); + setAllMatchings([...allMatchings, data]); + } + }; + + const handleError = (data: string) => { + alert(data); + }; + + if (socket) { + socket.emit('getAllMatchings', { userId: currentUserId }); + socket.emit('getMatching', { userId: currentUserId }); + socket.on('matchings', getAllMatchings); + socket.on('nextMatching', getNewMatching); + socket.on('error', handleError); + } + + return () => { + if (socket) { + socket.off('matchings'); + socket.off('nextMatching'); + socket.off('error'); + } + }; + }, [socket]); + + return ( + + { + nav(-1); + }} + $withBorder={true} + /> + + {allMatchings.map((matching: MatchingData) => { + console.log(matching); + return ( +
+ + +
+ ); + })} + {!hasNewMatching && } +
+ + + + ); +}; + +export default memo(MatchingRoom); diff --git a/src/pages/Chats/MatchingRoom/styles.tsx b/src/pages/Chats/MatchingRoom/styles.tsx new file mode 100644 index 00000000..b8eec77c --- /dev/null +++ b/src/pages/Chats/MatchingRoom/styles.tsx @@ -0,0 +1,13 @@ +import { styled } from 'styled-components'; + +export const MessagesContainer = styled.div<{ $isLoading: boolean }>` + visibility: ${({ $isLoading }) => ($isLoading ? 'hidden' : 'visible')}; + width: 100%; + overflow-y: scroll; + display: flex; + flex: 1; + flex-direction: column; + padding: 1.25rem 1.25rem 0 1.25rem; + margin: 0 auto 3.2rem auto; + scroll-behavior: smooth; +`; diff --git a/src/pages/Chats/RcvdMessage/dto.ts b/src/pages/Chats/RcvdMessage/dto.ts new file mode 100644 index 00000000..5563feef --- /dev/null +++ b/src/pages/Chats/RcvdMessage/dto.ts @@ -0,0 +1,9 @@ +export interface RcvdMessageProps { + fromUserNickname: string; + profilePictureUrl: string; + content: string; + isSenderChanged: boolean; // 상단 마진 추가 여부 + isProfileImageVisible: boolean; // 사용자 프로필 표시 여부 + isTimeVisible: boolean; // 메시지 옆 시간 표시 여부 + formattedTime: string; // 타임스탬프를 HH:MM 형태로 변환한 값 +} diff --git a/src/pages/Chats/ChatRoom/RcvdMessage/index.tsx b/src/pages/Chats/RcvdMessage/index.tsx similarity index 67% rename from src/pages/Chats/ChatRoom/RcvdMessage/index.tsx rename to src/pages/Chats/RcvdMessage/index.tsx index 8fcdfad2..3bbfae2b 100644 --- a/src/pages/Chats/ChatRoom/RcvdMessage/index.tsx +++ b/src/pages/Chats/RcvdMessage/index.tsx @@ -1,12 +1,12 @@ -import { memo } from 'react'; +import { memo, ReactNode } from 'react'; import theme from '@styles/theme'; -import type { RcvdMessageProps } from '../dto'; +import type { RcvdMessageProps } from './dto'; import { FirstMessageLayout, UserImage, UsernameText, MessageBox, Message, TimeWrapper, MessageLayout } from './styles'; -const RcvdMessage: React.FC void }> = memo( +const RcvdMessage: React.FC void; children?: ReactNode }> = memo( ({ fromUserNickname, profilePictureUrl, @@ -16,12 +16,13 @@ const RcvdMessage: React.FC void }> = isTimeVisible, formattedTime, onClickProfile, + children, }) => { if (isProfileImageVisible) { return ( - +
void }> = > {fromUserNickname} - - {content} - - - {isTimeVisible && {formattedTime}} + + + {children} + {content} + + {isTimeVisible && {formattedTime}} + +
); } else { return ( + {children} {content} {isTimeVisible && {formattedTime}} diff --git a/src/pages/Chats/ChatRoom/RcvdMessage/styles.tsx b/src/pages/Chats/RcvdMessage/styles.tsx similarity index 96% rename from src/pages/Chats/ChatRoom/RcvdMessage/styles.tsx rename to src/pages/Chats/RcvdMessage/styles.tsx index 1c659f2b..4252a598 100644 --- a/src/pages/Chats/ChatRoom/RcvdMessage/styles.tsx +++ b/src/pages/Chats/RcvdMessage/styles.tsx @@ -24,13 +24,12 @@ export const UserImage = styled.img` export const UsernameText = styled(StyledText)` cursor: pointer; + margin-bottom: 0.2rem; `; export const MessageBox = styled.div` display: flex; - flex-direction: column; gap: 0.2rem; - max-width: 75%; margin-right: 0.5rem; `; diff --git a/src/pages/Chats/ChatRoomItem/index.tsx b/src/pages/Chats/RecentChat/ChatRoomItem/index.tsx similarity index 96% rename from src/pages/Chats/ChatRoomItem/index.tsx rename to src/pages/Chats/RecentChat/ChatRoomItem/index.tsx index 6e04a3d5..e7f825d9 100644 --- a/src/pages/Chats/ChatRoomItem/index.tsx +++ b/src/pages/Chats/RecentChat/ChatRoomItem/index.tsx @@ -52,7 +52,7 @@ const ChatRoomItem: React.FC = ({ id, otherUser, latestMessage }) {otherUser?.nickname || '알수없음'} - + {latestMessage.content} diff --git a/src/pages/Chats/ChatRoomItem/styles.tsx b/src/pages/Chats/RecentChat/ChatRoomItem/styles.tsx similarity index 100% rename from src/pages/Chats/ChatRoomItem/styles.tsx rename to src/pages/Chats/RecentChat/ChatRoomItem/styles.tsx diff --git a/src/pages/Chats/RecentChat/MatchingRoomItem/index.tsx b/src/pages/Chats/RecentChat/MatchingRoomItem/index.tsx new file mode 100644 index 00000000..5de67548 --- /dev/null +++ b/src/pages/Chats/RecentChat/MatchingRoomItem/index.tsx @@ -0,0 +1,63 @@ +import { useEffect, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; + +import dayjs, { extend } from 'dayjs'; +import relativeTime from 'dayjs/plugin/relativeTime'; + +import theme from '@styles/theme'; + +import { LatestMatchingData } from '@apis/matching/dto'; + +import defaultProfile from '@assets/default/defaultProfile.svg'; + +import { StyledText } from '@components/Text/StyledText'; + +import { UserImage, MatchingRoomLayout, LeftBox, RightBox, LatestMessage } from './styles'; + +const MatchingRoomItem: React.FC> = ({ requestStatus, createdAt }) => { + const [timeAgo, setTimeAgo] = useState(null); + const nav = useNavigate(); + extend(relativeTime); + + const handleMatchingRoomClick = () => { + nav(`/matching`); + }; + + useEffect(() => { + if (createdAt) { + // 초기 시간 설정 + setTimeAgo(dayjs(createdAt).locale('ko').fromNow()); + + // 1초마다 `timeAgo`를 업데이트 + const interval = setInterval(() => { + setTimeAgo(dayjs(createdAt).locale('ko').fromNow()); + }, 1000); + + // 컴포넌트 언마운트 시 타이머 정리 + return () => clearInterval(interval); + } else { + setTimeAgo(null); + } + }, []); + + return ( + + + + + 오딩이 + + + {requestStatus === 'pending' ? '얘가 너 소개받고 싶대' : '매칭이 들어오면 오딩이가 알려줄게!'} + + + + + {timeAgo} + + + + ); +}; + +export default MatchingRoomItem; diff --git a/src/pages/Chats/RecentChat/MatchingRoomItem/styles.tsx b/src/pages/Chats/RecentChat/MatchingRoomItem/styles.tsx new file mode 100644 index 00000000..a6dbdb5c --- /dev/null +++ b/src/pages/Chats/RecentChat/MatchingRoomItem/styles.tsx @@ -0,0 +1,47 @@ +import { styled } from 'styled-components'; + +import { StyledText } from '@components/Text/StyledText'; + +export const MatchingRoomLayout = styled.li` + width: 100%; + display: grid; + grid-template-columns: auto 1fr auto; + margin: 0 auto; + cursor: pointer; +`; + +export const UserImage = styled.img` + width: 3.25rem; + height: 3.25rem; + object-fit: cover; + border-radius: 50%; + box-shadow: + 0px 1px 2px 0px rgba(0, 0, 0, 0.12), + 0px 0px 1px 0px rgba(0, 0, 0, 0.08), + 0px 0px 1px 0px rgba(0, 0, 0, 0.08); +`; + +export const LeftBox = styled.div` + margin: 0.2rem 0.5rem; + display: flex; + flex-direction: column; + gap: 0.3rem; + overflow: hidden; +`; + +export const LatestMessage = styled(StyledText)` + display: -webkit-box; + -webkit-line-clamp: 1; + -webkit-box-orient: vertical; + overflow: hidden; + overflow-wrap: break-word; + text-overflow: ellipsis; +`; + +export const RightBox = styled.div` + margin: 0.1rem 0 0.1rem auto; + text-align: right; + display: flex; + flex-direction: column; + justify-content: space-between; +`; diff --git a/src/pages/Chats/RecentChat/index.tsx b/src/pages/Chats/RecentChat/index.tsx index b0de9f2b..54a2f4ab 100644 --- a/src/pages/Chats/RecentChat/index.tsx +++ b/src/pages/Chats/RecentChat/index.tsx @@ -1,32 +1,29 @@ import { useEffect, useState } from 'react'; -import SwiperCore from 'swiper'; - import theme from '@styles/theme'; +import { LatestMatchingData } from '@apis/matching/dto'; import { useSocket } from '@context/SocketProvider'; import { getCurrentUserId } from '@utils/getCurrentUserId'; import Loading from '@components/Loading'; -import { StyledText } from '@components/Text/StyledText'; import type { ChatRoomData } from '@apis/chatting/dto'; -import ChatRoomItem from '../ChatRoomItem/index'; - -import { ChatRoomList, NoChatRoomWrapper, RecentChatInfo } from './styles'; +import ChatRoomItem from './ChatRoomItem/index'; +import MatchingRoomItem from './MatchingRoomItem/index'; -interface RecentChatProps { - matchingCount: number; - swiperRef: React.MutableRefObject; -} +import { ChatRoomList, RecentChatInfo } from './styles'; -const RecentChat: React.FC = () => { +const RecentChat: React.FC = () => { const [chatRoomList, setChatRoomList] = useState([]); + const [latestMatching, setLatestMatching] = useState(); const [isLoading, setIsLoading] = useState(true); - const socket = useSocket(); const currentUserId = getCurrentUserId(); + const socket = useSocket(); + const matchingSocket = useSocket('matching'); + useEffect(() => { // 채팅방 리스트 조회 const getChatRooms = (data: ChatRoomData[]) => { @@ -34,41 +31,58 @@ const RecentChat: React.FC = () => { setIsLoading(false); }; + // 최근 매칭 조회 + const getLatestMatching = (data: LatestMatchingData) => { + setLatestMatching(data); + }; + + const matchingNotFound = (data: { joinedAt: Date }) => { + setLatestMatching({ + createdAt: data.joinedAt, + }); + }; + if (socket) { socket.emit('getChatRooms', { userId: currentUserId }); socket.on('chatRoomList', getChatRooms); } + if (matchingSocket) { + matchingSocket.emit('getLatestMatching', { userId: currentUserId }); + matchingSocket.on('getLatestMatching', getLatestMatching); + matchingSocket.on('matchingNotFound', matchingNotFound); + } + // 이벤트 리스너 정리 // 컴포넌트가 언마운트되면 더 이상 이벤트를 수신하지 않음 return () => { if (socket) { socket.off('getChatRooms', getChatRooms); } + + if (matchingSocket) { + matchingSocket.off('getLatestMatching', getLatestMatching); + matchingSocket.off('matchingNotFound', matchingNotFound); + } }; - }, [socket]); + }, [socket, matchingSocket]); return ( <> {isLoading ? ( - ) : chatRoomList.length !== 0 ? ( + ) : ( <> 최근 채팅방 + {chatRoomList.map((chatRoom) => ( ))} - ) : ( - - - 개설된 채팅방이 없어요. - - )} ); diff --git a/src/pages/Chats/RecentChat/styles.tsx b/src/pages/Chats/RecentChat/styles.tsx index 7de1cfe6..90938f64 100644 --- a/src/pages/Chats/RecentChat/styles.tsx +++ b/src/pages/Chats/RecentChat/styles.tsx @@ -13,11 +13,3 @@ export const ChatRoomList = styled.ul` gap: 1rem; padding: 0.62rem 1.25rem; `; - -export const NoChatRoomWrapper = styled.div` - width: 100%; - height: 100%; - display: flex; - justify-content: center; - align-items: center; -`; diff --git a/src/pages/Chats/SentMessage/dto.ts b/src/pages/Chats/SentMessage/dto.ts new file mode 100644 index 00000000..a4c97184 --- /dev/null +++ b/src/pages/Chats/SentMessage/dto.ts @@ -0,0 +1,6 @@ +export interface SentMessageProps { + content: string; + isSenderChanged: boolean; // 상단 마진 추가 여부 + isTimeVisible: boolean; // 메시지 옆 시간 표시 여부 + formattedTime: string; // 타임스탬프를 HH:MM 형태로 변환한 값 +} diff --git a/src/pages/Chats/ChatRoom/SentMessage/index.tsx b/src/pages/Chats/SentMessage/index.tsx similarity index 91% rename from src/pages/Chats/ChatRoom/SentMessage/index.tsx rename to src/pages/Chats/SentMessage/index.tsx index 6ad94e05..e6aabad1 100644 --- a/src/pages/Chats/ChatRoom/SentMessage/index.tsx +++ b/src/pages/Chats/SentMessage/index.tsx @@ -2,7 +2,7 @@ import { memo } from 'react'; import theme from '@styles/theme'; -import type { SentMessageProps } from '../dto'; +import type { SentMessageProps } from './dto'; import { Message, TimeWrapper, MessageLayout } from './styles'; diff --git a/src/pages/Chats/ChatRoom/SentMessage/styles.tsx b/src/pages/Chats/SentMessage/styles.tsx similarity index 100% rename from src/pages/Chats/ChatRoom/SentMessage/styles.tsx rename to src/pages/Chats/SentMessage/styles.tsx diff --git a/src/pages/Chats/TabBar/index.tsx b/src/pages/Chats/TabBar/index.tsx deleted file mode 100644 index a48c9607..00000000 --- a/src/pages/Chats/TabBar/index.tsx +++ /dev/null @@ -1,136 +0,0 @@ -import { memo, useCallback, useEffect, useRef, useState } from 'react'; - -import SwiperCore from 'swiper'; -import { Swiper, SwiperSlide } from 'swiper/react'; - -import theme from '@styles/theme'; - -import { getMatchingListApi } from '@apis/matching'; - -import { StyledText } from '@components/Text/StyledText'; - -import Matching from '../Matching/index'; -import RecentChat from '../RecentChat/index'; - -import { TabBarLayout, TabBarContainer, TabBarWrapper, TabBarList, Tabs } from './styles'; - -import 'swiper/css'; - -const TabBar: React.FC = () => { - const [matchingCount, setMatchingCount] = useState(0); - const [hasMatchingRequest, setHasMatchingRequest] = useState(false); - - const [activeIndex, setActiveIndex] = useState(1); - const swiperRef = useRef(null); - const tabs = [`요청 ${activeIndex === 1 ? matchingCount : ''}`, '최근 채팅']; - - // request 컴포넌트에서 매칭 거절 시 matchingCount 감소 - const decreaseMatchingCount = useCallback(() => { - if (matchingCount !== 1) { - setMatchingCount((prev) => Math.max(0, prev - 1)); - } else { - setHasMatchingRequest(false); - swiperRef.current?.slideNext(); - } - }, [matchingCount]); - - // 매칭 요청이 있는 경우에만 '요청' 탭을 활성화 - const handleTabClick = useCallback( - (index: number) => { - if (index !== 0 || hasMatchingRequest) { - setActiveIndex(index); - if (swiperRef.current) { - swiperRef.current.slideTo(index); - } - } - }, - [hasMatchingRequest], - ); - - // 슬라이드가 변경될 때 호출 - const handleSlideChange = useCallback( - (swiper: SwiperCore) => { - // 매칭 요청이 없고 1번 index에 있을 때 0번 탭 비활성화 - if (!hasMatchingRequest && swiper.activeIndex > swiper.previousIndex) { - swiper.allowSlidePrev = false; - setActiveIndex(swiper.activeIndex); - } - // 매칭 요청이 있을 때 양쪽 스와이퍼 가능 - else { - swiper.allowSlidePrev = true; - setActiveIndex(swiper.activeIndex); - } - }, - [hasMatchingRequest], - ); - - // 매칭 리스트 조회 api - const getMatchingList = async () => { - const response = await getMatchingListApi(); - - if (response.isSuccess) { - setMatchingCount(response.data.matchingsCount); - setHasMatchingRequest(response.data.hasMatching); - } - }; - - useEffect(() => { - // 첫 탭을 최근 채팅으로 설정 - if (swiperRef.current) { - swiperRef.current.slideTo(1, 0); - } - - getMatchingList(); - }, []); - - return ( - - - - {tabs.map((tab, index) => ( - handleTabClick(index)} - > - - {tab} - - - ))} - - - - { - swiperRef.current = swiper; - }} - onSlideChange={handleSlideChange} - allowSlidePrev={hasMatchingRequest} - spaceBetween={0} - slidesPerView={1} - autoHeight={true} // 각 슬라이드 높이를 자동으로 조정 - > - - - - - - - - - - ); -}; - -export default memo(TabBar); diff --git a/src/pages/Chats/TabBar/styles.tsx b/src/pages/Chats/TabBar/styles.tsx deleted file mode 100644 index 05b2cae6..00000000 --- a/src/pages/Chats/TabBar/styles.tsx +++ /dev/null @@ -1,55 +0,0 @@ -import { styled } from 'styled-components'; - -export const TabBarLayout = styled.div` - display: flex; - flex-direction: column; - margin-top: 0; - flex: 1; -`; - -export const TabBarContainer = styled.div` - width: 100%; - margin: 0 auto; - display: flex; - justify-content: space-around; - align-items: flex-end; -`; - -export const TabBarList = styled.ul` - display: flex; - flex: 1; - justify-content: space-between; -`; - -export const TabBarWrapper = styled.li<{ $isSelected: boolean; $isPointer: boolean }>` - border-bottom: 0.13rem solid ${({ theme }) => theme.colors.border.divider}; - border-image: ${({ $isSelected, theme }) => ($isSelected ? `${theme.colors.brand.gradient} 0 0 1 0` : 'transparent')}; - text-align: center; - flex-grow: 1; - padding: 0.62rem; - padding-bottom: 0.4rem; - cursor: ${({ $isPointer }) => ($isPointer ? 'pointer' : '')}; -`; - -export const Tabs = styled.div` - width: 100%; - height: 100%; - - .swiper { - height: 100%; - } - - .swiper-wrapper { - height: 100%; - } - - .swiper-slider { - height: 100%; - overflow-y: scroll; - padding-bottom: 0.7rem; - - &::-webkit-scrollbar { - display: none; - } - } -`; diff --git a/src/pages/Chats/index.tsx b/src/pages/Chats/index.tsx index bf4ff29f..1b2e3f9a 100644 --- a/src/pages/Chats/index.tsx +++ b/src/pages/Chats/index.tsx @@ -3,8 +3,7 @@ import theme from '@styles/theme'; import { OODDFrame } from '@components/Frame/Frame'; import NavBar from '@components/NavBar'; -import TabBar from './TabBar/index'; - +import RecentChat from './RecentChat'; import { Header } from './styles'; const Chats: React.FC = () => { @@ -13,7 +12,7 @@ const Chats: React.FC = () => {
Chats
- + ); diff --git a/src/pages/Home/OOTD/Feed/index.tsx b/src/pages/Home/OOTD/Feed/index.tsx index 842e6580..f854c3b4 100644 --- a/src/pages/Home/OOTD/Feed/index.tsx +++ b/src/pages/Home/OOTD/Feed/index.tsx @@ -10,15 +10,16 @@ import 'swiper/css/pagination'; import theme from '@styles/theme'; -import { createMatchingApi } from '@apis/matching'; import { togglePostLikeStatusApi } from '@apis/post-like'; import { postUserBlockApi } from '@apis/user-block'; import { handleError } from '@apis/util/handleError'; +import { useSocket } from '@context/SocketProvider'; import { getCurrentUserId } from '@utils/getCurrentUserId'; import defaultProfile from '@assets/default/defaultProfile.svg'; import more from '@assets/default/more.svg'; import xBtn from '@assets/default/reject.svg'; +import share from '@assets/default/share.svg'; import Heart from '@components/Icons/Heart'; import Message from '@components/Icons/Message'; @@ -28,7 +29,6 @@ import OptionsBottomSheet from '@components/BottomSheet/OptionsBottomSheet'; import Modal from '@components/Modal'; import { StyledText } from '@components/Text/StyledText'; -import type { CreateMatchingRequest } from '@apis/matching/dto'; import type { PostUserBlockRequest } from '@apis/user-block/dto'; import type { CommentBottomSheetProps } from '@components/BottomSheet/CommentBottomSheet/dto'; import { OptionsBottomSheetProps } from '@components/BottomSheet/OptionsBottomSheet/dto'; @@ -64,6 +64,8 @@ const Feed: React.FC = ({ feed }) => { const currentUserId = getCurrentUserId(); const timeAgo = dayjs(feed.createdAt).locale('ko').fromNow(); + const socket = useSocket('matching'); + const handleMoreButtonClick = (e: React.MouseEvent) => { e.stopPropagation(); setIsOptionsBottomSheetOpen(true); @@ -140,26 +142,23 @@ const Feed: React.FC = ({ feed }) => { } }; - // 매칭 생성 api - const createMatching = async (comment: string) => { - try { - const matchingRequest: CreateMatchingRequest = { - requesterId: currentUserId || -1, - targetId: feed.user.id || -1, - message: comment, - }; - const response = await createMatchingApi(matchingRequest); + // 매칭 신청 socket api + const createMatching = (comment: string) => { + socket.emit('requestMatching', { + requesterId: currentUserId, + targetId: feed.user.id, + message: comment, + }); - if (response.isSuccess) { - setModalContent(`${feed.user.nickname} 님에게 대표 OOTD와\n한 줄 메세지를 보냈어요!`); - } - } catch (error) { - const errorMessage = handleError(error, 'user'); - setModalContent(errorMessage); - } finally { - setIsMatchingCommentBottomSheetOpen(false); + socket.on('error', (data) => { + setModalContent(data); setIsStatusModalOpen(true); - } + + // 리스너가 중복 등록되지 않도록 바로 정리 + socket.off('error'); + }); + + setIsMatchingCommentBottomSheetOpen(false); }; // 게시글 옵션(더보기) 바텀시트 @@ -209,6 +208,24 @@ const Feed: React.FC = ({ feed }) => { }, }; + // 친구한테 프로필 공유하기 + const handleShareButtonClick = (e: React.MouseEvent) => { + e.stopPropagation(); + // 사용자 ID로 프로필 URL 생성 + const profileUrl = `${window.location.origin}/profile/${feed.user.id}`; + + navigator.clipboard + .writeText(profileUrl) + .then(() => { + setModalContent(`${feed.user.nickname}님의 프로필이 복사되었습니다!`); + setIsStatusModalOpen(true); // 복사 성공 후 모달 열기 + }) + .catch(() => { + setModalContent('프로필 복사에 실패했습니다. 다시 시도해 주세요.'); + setIsStatusModalOpen(true); // 복사 실패 시 모달 열기 + }); + }; + return ( @@ -251,7 +268,11 @@ const Feed: React.FC = ({ feed }) => {
+
+ 공유 +
+ diff --git a/src/pages/Profile/ButtonSecondary/styles.tsx b/src/pages/Profile/ButtonSecondary/styles.tsx index af4a1820..14b7e3f7 100644 --- a/src/pages/Profile/ButtonSecondary/styles.tsx +++ b/src/pages/Profile/ButtonSecondary/styles.tsx @@ -1,14 +1,15 @@ import { styled } from 'styled-components'; export const Button = styled.button` - width: 100%; - margin: 16px auto; + width: 90%; + margin: 1rem auto; height: 3.1rem; text-align: center; color: ${({ theme }) => theme.colors.brand.primary}; cursor: pointer; box-sizing: border-box; - border: 1px solid; - border-radius: 10px; - padding: 10px; + border: 0.0625rem solid; + border-radius: 0.625rem; + padding: 0.625rem; + padding-inline: 1rem; `; diff --git a/src/pages/Profile/NavbarProfile/styles.tsx b/src/pages/Profile/NavbarProfile/styles.tsx index ac142d87..2726d440 100644 --- a/src/pages/Profile/NavbarProfile/styles.tsx +++ b/src/pages/Profile/NavbarProfile/styles.tsx @@ -4,7 +4,7 @@ export const Nav = styled.nav` display: flex; justify-content: space-between; align-items: center; - padding: 8px 20px; + padding: 0.5rem 1.25rem; position: fixed; top: 0; left: 0; @@ -17,7 +17,7 @@ export const Nav = styled.nav` export const IconContainer = styled.div` display: flex; align-items: center; - margin-right: 18px; + margin-right: 5px; a { display: flex; diff --git a/src/pages/Profile/ProfileEdit/index.tsx b/src/pages/Profile/ProfileEdit/index.tsx index a3526002..b9eb24a3 100644 --- a/src/pages/Profile/ProfileEdit/index.tsx +++ b/src/pages/Profile/ProfileEdit/index.tsx @@ -49,6 +49,7 @@ const ProfileEdit: React.FC = () => { const [birthDate, setBirthDate] = useState(''); const [name, setName] = useState(''); const [email, setEmail] = useState(''); + const [userStyletags, setUserStyletags] = useState([]); const [isLoading, setIsLoading] = useState(true); const navigate = useNavigate(); const [modalContent, setModalContent] = useState(null); @@ -74,6 +75,7 @@ const ProfileEdit: React.FC = () => { setBirthDate(userInfo.birthDate || ''); setName(userInfo.name || ''); setEmail(userInfo.email || ''); + setUserStyletags(userInfo.userStyletags || []); } catch (error) { console.error('Error fetching user info:', error); } finally { @@ -128,6 +130,7 @@ const ProfileEdit: React.FC = () => { nickname: nickname || '닉네임 없음', profilePictureUrl: profilePictureUrl || '', bio: bio || '', + userStyletags: userStyletags || [], }; const response = await patchUserInfoApi(payload, currentUserId); diff --git a/src/pages/Profile/ProfileEdit/styles.tsx b/src/pages/Profile/ProfileEdit/styles.tsx index 014fd396..5c94df97 100644 --- a/src/pages/Profile/ProfileEdit/styles.tsx +++ b/src/pages/Profile/ProfileEdit/styles.tsx @@ -14,7 +14,7 @@ export const ProfilePicWrapper = styled.div` display: flex; flex-direction: column; align-items: center; - margin-bottom: 10px; + margin-bottom: 0.625rem; position: relative; `; @@ -25,19 +25,19 @@ export const Label = styled.label` export const Input = styled.input` width: 100%; - padding: 25px; - margin: 10px 0; - border: 0px; + padding: 1.5625rem; + margin: 0.625rem 0; + border: 0rem; box-sizing: border-box; - border-radius: 10px; + border-radius: 0.625rem; background-color: ${({ theme }) => theme.colors.background.secondary}; text-align: left; `; export const Button = styled.button` position: absolute; - bottom: 10px; - right: 10px; + bottom: 0.625rem; + right: 0.625rem; z-index: 1; width: 1.7rem; height: 1.7rem; @@ -50,18 +50,22 @@ export const Button = styled.button` `; export const ProfilePic = styled.div` - width: 7.25rem; - height: 7.25rem; + width: 7.5rem; + height: 7.5rem; flex-shrink: 0; border-radius: 50%; overflow: hidden; - margin-top: 2.125rem; - margin-bottom: 15px; + margin-top: 2rem; + margin-bottom: 0.9375rem; img { width: 100%; height: 100%; object-fit: cover; + box-shadow: + 0px 2px 8px 0px rgba(0, 0, 0, 0.12), + 0px 1px 4px 0px rgba(0, 0, 0, 0.08), + 0px 0px 1px 0px rgba(0, 0, 0, 0.08); } `; @@ -72,8 +76,8 @@ export const Row = styled.div` flex-direction: column; align-items: stretch; width: 100%; - margin-top: 0px; - padding: 0px 20px; + margin-top: 0rem; + padding: 0rem 1.25rem; ${Label} { width: 6.25rem; @@ -81,12 +85,12 @@ export const Row = styled.div` `; export const EmailInput = styled.input` - margin-bottom: 120px; + margin-bottom: 7.5rem; width: 100%; - padding: 25px; + padding: 1.5625rem; border: 0px; box-sizing: border-box; - border-radius: 10px; + border-radius: 0.625rem; background-color: ${({ theme }) => theme.colors.background.secondary}; text-align: left; `; @@ -100,9 +104,9 @@ export const UserInfo = styled.div``; export const Username = styled.button` color: ${({ theme }) => theme.colors.text.primary}; font-family: Pretendard; - font-size: 22px; + font-size: 1.375rem; font-style: normal; font-weight: 700; line-height: 136.4%; - letter-spacing: -0.427px; + letter-spacing: -0.0267rem; `; diff --git a/src/pages/Profile/UserProfile/style.tsx b/src/pages/Profile/UserProfile/style.tsx index 4d2d07aa..b7fe4712 100644 --- a/src/pages/Profile/UserProfile/style.tsx +++ b/src/pages/Profile/UserProfile/style.tsx @@ -11,6 +11,10 @@ export const UserImg = styled.img` height: 4.5rem; object-fit: cover; border-radius: 50%; + box-shadow: + 0px 2px 8px 0px rgba(0, 0, 0, 0.12), + 0px 1px 4px 0px rgba(0, 0, 0, 0.08), + 0px 0px 1px 0px rgba(0, 0, 0, 0.08); `; export const UserDetailsContainer = styled.section` diff --git a/src/pages/Profile/index.tsx b/src/pages/Profile/index.tsx index 4953104f..5b8a41e6 100644 --- a/src/pages/Profile/index.tsx +++ b/src/pages/Profile/index.tsx @@ -5,9 +5,9 @@ import { useRecoilValue } from 'recoil'; import theme from '@styles/theme'; -import { createMatchingApi } from '@apis/matching'; import { getUserPostListApi } from '@apis/post'; import { getUserInfoApi } from '@apis/user'; +import { useSocket } from '@context/SocketProvider'; import { OtherUserAtom } from '@recoil/util/OtherUser'; import { getCurrentUserId } from '@utils/getCurrentUserId'; @@ -56,6 +56,7 @@ const Profile: React.FC = () => { const [isModalOpen, setIsModalOpen] = useState(false); const [modalContent, setModalContent] = useState(''); const navigate = useNavigate(); + const socket = useSocket('matching'); const { userId } = useParams<{ userId: string }>(); const profileUserId = Number(userId); @@ -81,26 +82,22 @@ const Profile: React.FC = () => { fetchData(); }, [profileUserId]); - const createMatching = async (message: string) => { - const matchingRequestData = { + const createMatching = (comment: string) => { + socket.emit('requestMatching', { requesterId: currentUserId, targetId: otherUser?.id || profileUserId, - message: message, - }; + message: comment, + }); - try { - await createMatchingApi(matchingRequestData); - handleModalOpen(`${userInfo?.nickname}님에게 대표 OOTD와 \n한 줄 메세지를 보냈어요!`); - } catch (error) { - console.error('매칭 신청 오류:', error); - handleModalOpen('매칭 신청에 실패했습니다.'); - } - }; + socket.on('error', (data) => { + setModalContent(data); + setIsModalOpen(true); + + // 리스너가 중복 등록되지 않도록 바로 정리 + socket.off('error'); + }); - const handleModalOpen = (message: string) => { setIsBottomSheetOpen(false); - setModalContent(message); - setIsModalOpen(true); }; if (isLoading) return ; diff --git a/src/pages/Profile/styles.tsx b/src/pages/Profile/styles.tsx index 7db238d6..50c6463f 100644 --- a/src/pages/Profile/styles.tsx +++ b/src/pages/Profile/styles.tsx @@ -13,7 +13,7 @@ export const ProfileContainer = styled.div` `; export const Header = styled.div` - margin: 8px 20px; + margin: 0.5rem 1.25rem; display: flex; align-items: center; padding: 0rem; @@ -57,9 +57,9 @@ export const PostsContainer = styled.div` display: flex; flex-wrap: wrap; justify-content: space-between; - gap: 15px; + gap: 0.9375rem; margin-bottom: 100px; - padding: 20px; + padding: 1.25rem; `; export const AddButton = styled.button` @@ -79,7 +79,7 @@ export const NoPostWrapper = styled.div` display: flex; justify-content: center; align-items: center; - margin-top: 80px; + margin-top: 5rem; `; export const Button = styled.button` @@ -90,7 +90,7 @@ export const Button = styled.button` color: ${({ theme }) => theme.colors.text.contrast}; cursor: pointer; box-sizing: border-box; - border-radius: 10px; - padding: 10px; + border-radius: 0.625rem; + padding: 0.625rem; background: ${({ theme }) => theme.colors.brand.gradient}; `; diff --git a/src/pages/SignUp/PickMyStyle/index.tsx b/src/pages/SignUp/PickMyStyle/index.tsx new file mode 100644 index 00000000..ca9fc599 --- /dev/null +++ b/src/pages/SignUp/PickMyStyle/index.tsx @@ -0,0 +1,122 @@ +import { useEffect, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; + +import { getUserInfoApi, patchUserInfoApi } from '@apis/user'; +import { PatchUserInfoRequest } from '@apis/user/dto'; +import { getCurrentUserId } from '@utils/getCurrentUserId'; +import { styleImages } from '@utils/styleImages'; + +import Back from '@assets/arrow/left.svg'; + +import BottomButton from '@components/BottomButton'; +import { OODDFrame } from '@components/Frame/Frame'; +import Modal from '@components/Modal'; +import TopBar from '@components/TopBar'; + +import { PickMyStyleLayout, StyledSubTitle, StyledTitle, CategoryList, PlaceholderImage } from './style'; + +const PickMyStyle: React.FC = () => { + const [nickname, setNickname] = useState(''); + const [clickedImages, setClickedImages] = useState<{ [key: number]: boolean }>({}); + + const [isModalOpen, setIsModalOpen] = useState(false); + const [modalMessage, setModalMessage] = useState(''); + + const navigate = useNavigate(); + const currentUserId = getCurrentUserId(); + + // 유저 정보 가져오기 + useEffect(() => { + const getUserInfo = async () => { + try { + const userInfo = await getUserInfoApi(currentUserId); + setNickname(userInfo.data.nickname); + } catch (error) { + console.error('유저 정보 불러오기 실패:', error); + } + }; + getUserInfo(); + }, [currentUserId]); + + // 이미지 클릭 시 상태 변경 + const handleImageClick = (id: number) => { + setClickedImages((prev) => ({ + ...prev, + [id]: !prev[id], // 클릭할 때마다 토글 + })); + }; + + const handleSubmitBtnClick = async () => { + const selectedCategories = Object.keys(clickedImages) + .filter((id) => clickedImages[Number(id)]) // 클릭된 이미지만 필터링 + .map((id) => styleImages.find((img) => img.id === Number(id))?.category) // category 값 가져오기 + .filter((category): category is string => !!category); // undefined 제거 + + const requestData: Partial = { + userStyletags: selectedCategories, + }; + console.log(requestData); + + try { + const data = await patchUserInfoApi(requestData, currentUserId); + console.log(data); + navigate('/'); + } catch (error) { + console.error('API 요청 실패:', error); + setModalMessage('스타일 선택 중 오류가 발생했습니다.'); + console.log(requestData); + setIsModalOpen(true); + } + }; + + const handleModalClose = () => { + setIsModalOpen(false); + }; + + return ( + + { + window.history.back(); + }} + /> + + + {nickname}님의 취향을 알려주세요! + + + OODD가 당신의 취향을 분석하여 맞춤 스타일을 추천해 드릴게요. + + + {styleImages.map((image) => ( + handleImageClick(image.id)} + data-category={image.category} + > + {`${image.category} + + ))} + + + {isModalOpen && } + + + ); +}; + +export default PickMyStyle; diff --git a/src/pages/SignUp/PickMyStyle/style.tsx b/src/pages/SignUp/PickMyStyle/style.tsx new file mode 100644 index 00000000..defc0c93 --- /dev/null +++ b/src/pages/SignUp/PickMyStyle/style.tsx @@ -0,0 +1,76 @@ +import { styled } from 'styled-components'; + +import { StyledText } from '@components/Text/StyledText'; + +export const OODDFrame = styled.div` + width: 100%; + height: 100vh; // 화면 전체 높이 차지 + overflow: hidden; // 전체 화면 스크롤 방지 + display: flex; + flex-direction: column; +`; + +export const PickMyStyleLayout = styled.div` + display: flex; + flex-direction: column; + padding: 0 1.875rem; + flex: 1; // 남은 공간을 다 차지하도록 설정 + width: 100%; + height: 100%; + overflow: hidden; // 상위 요소의 스크롤 방지 +`; + +export const StyledTitle = styled(StyledText)` + margin: 0.625rem 0; +`; + +export const StyledSubTitle = styled(StyledText)` + margin-bottom: 1.25rem; +`; + +export const CategoryList = styled.div` + width: 100%; + max-width: 31.25rem; + margin: auto; + flex-grow: 1; + overflow-y: auto; + padding-bottom: 6.25rem; + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 0.625rem; + + // 스크롤바 숨기기 + &::-webkit-scrollbar { + display: none; + } + scrollbar-width: none; +`; + +export const PlaceholderImage = styled.div<{ $isClicked: boolean }>` + width: 100%; + aspect-ratio: 1; + background-color: lightgray; + display: flex; + align-items: center; + justify-content: center; + font-size: 0.875rem; + border-radius: 0.5rem; + transition: + transform 0.2s ease, + box-shadow 0.2s ease; + + transform: ${({ $isClicked }) => ($isClicked ? 'scale(0.9)' : 'scale(1)')}; + box-shadow: ${({ $isClicked }) => ($isClicked ? '0 0.125rem 0.25rem rgba(0, 0, 0, 0.2)' : 'none')}; + + &:hover { + transform: ${({ $isClicked }) => ($isClicked ? 'scale(0.9)' : 'scale(0.95)')}; + } + cursor: pointer; + + img { + width: 100%; + height: 100%; + object-fit: cover; + border-radius: 0.5rem; + } +`; diff --git a/src/pages/SignUp/TermsAgreement/index.tsx b/src/pages/SignUp/TermsAgreement/index.tsx index fee82a38..4d9cb54c 100644 --- a/src/pages/SignUp/TermsAgreement/index.tsx +++ b/src/pages/SignUp/TermsAgreement/index.tsx @@ -67,7 +67,7 @@ const TermsAgreement: React.FC = () => { try { const response = await postTermsAgreementApi(currentUserId); console.log(response); - navigate('/'); // 성공 시 홈으로 이동 + navigate('/signup/pick-my-style'); // 성공 시 취향 선택 UI로 이동 } catch (error) { console.error('약관 동의 API 호출 실패:', error); const errorMessage = handleError(error); @@ -102,7 +102,7 @@ const TermsAgreement: React.FC = () => { onChange={handleAllAgreementChange} id="all-agreement" /> -