From d52e9979bffc712dc02a041600bb969b231c94ca Mon Sep 17 00:00:00 2001 From: joojjang Date: Wed, 13 Nov 2024 10:42:50 +0900 Subject: [PATCH 1/9] =?UTF-8?q?feat(login):=20=EB=A1=9C=EA=B7=B8=EC=9D=B8?= =?UTF-8?q?=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 회원인지를 클라이언트에서 체크하지 않음 - 로그인 요청 보내기만 하면 됨 --- src/apis/instance/index.ts | 2 +- src/apis/login/useGetKakaoLogin.ts | 39 ++++++++++++++++++++++++++++++ src/apis/queryKeys.ts | 1 + src/pages/Login/index.tsx | 24 +++++++++--------- 4 files changed, 52 insertions(+), 14 deletions(-) create mode 100644 src/apis/login/useGetKakaoLogin.ts diff --git a/src/apis/instance/index.ts b/src/apis/instance/index.ts index 0ab57c27..216f04ec 100644 --- a/src/apis/instance/index.ts +++ b/src/apis/instance/index.ts @@ -1,6 +1,6 @@ import axios, { type AxiosInstance, type AxiosRequestConfig } from 'axios'; -const BASE_URL = import.meta.env.VITE_APP_BASE_URL; +const BASE_URL = `${import.meta.env.VITE_APP_BASE_URL}/v1`; const initInstance = (config: AxiosRequestConfig): AxiosInstance => { const instance = axios.create({ diff --git a/src/apis/login/useGetKakaoLogin.ts b/src/apis/login/useGetKakaoLogin.ts new file mode 100644 index 00000000..085c8bc2 --- /dev/null +++ b/src/apis/login/useGetKakaoLogin.ts @@ -0,0 +1,39 @@ +// import { useSuspenseQuery } from '@tanstack/react-query'; +import { isAxiosError } from 'axios'; + +import fetchInstance from '../fetchInstance'; +// import QUERY_KEYS from '../queryKeys'; + +const BASE_URL = import.meta.env.VITE_APP_BASE_URL; + +type GetLoginResponse = { + accessToken: string; + refreshToken: string; +}; + +export async function getKakaoLgoin(): Promise { + try { + const response = await fetchInstance(BASE_URL).get(`/oauth2/login/kakao`); + + return response.data; + } catch (error) { + if (isAxiosError(error)) { + if (error.response) { + throw new Error(error.response.data.message || '로그인 페이지 이동 실패'); + } else { + throw new Error('네트워크 오류 또는 서버에 연결할 수 없습니다.'); + } + } else { + throw new Error('알 수 없는 오류입니다.'); + } + } +} + +// const useGetKakaoLogin = () => { +// return useSuspenseQuery({ +// queryKey: [QUERY_KEYS.LOGIN], +// queryFn: getKakaoLgoin, +// }); +// }; + +// export default useGetKakaoLogin; diff --git a/src/apis/queryKeys.ts b/src/apis/queryKeys.ts index 166dff24..d198cef4 100644 --- a/src/apis/queryKeys.ts +++ b/src/apis/queryKeys.ts @@ -1,4 +1,5 @@ const QUERY_KEYS = { + LOGIN: 'login', FEED: 'feed', FOLLOW_LIST: 'followList', USER_INFO: 'userInfo', diff --git a/src/pages/Login/index.tsx b/src/pages/Login/index.tsx index f0b17600..cf8a8097 100644 --- a/src/pages/Login/index.tsx +++ b/src/pages/Login/index.tsx @@ -2,6 +2,7 @@ import { Text } from '@chakra-ui/react'; import styled from '@emotion/styled'; import { useNavigate } from 'react-router-dom'; +import { getKakaoLgoin } from '@/apis/login/useGetKakaoLogin'; import KakaoSymbol from '@/assets/kakao-symbol.svg?react'; import Logo from '@/assets/logo.svg?react'; import IconButton from '@/components/common/IconButton'; @@ -11,7 +12,6 @@ import { RouterPath } from '@/routes/path'; import { HEIGHTS } from '@/styles/constants'; const Login = () => { - const isMember = false; // 추후 API 연동 const navigate = useNavigate(); // 랜덤 배경이미지 @@ -19,6 +19,10 @@ const Login = () => { const backgroundImage = BACKGROUND_IMAGE_LIST[randomIndex].src; const backgroundImageCreator = BACKGROUND_IMAGE_LIST[randomIndex].creator; + const handleLogin = () => { + getKakaoLgoin(); + }; + return (
{ 그 무한은 숨겨진 가치를 밝혀줍니다.
예술 속에 숨겨진 가치를 찾아드립니다. - + { export default Login; -type KakaoLoginButtonProps = { isMember: boolean }; - -const KakaoLoginButton = ({ isMember }: KakaoLoginButtonProps) => { - const navigate = useNavigate(); - - const handleLogin = () => { - if (!isMember) { - navigate(`/${RouterPath.signup}`); - } - }; +type KakaoLoginButtonProps = { + onClick: () => void; +}; +const KakaoLoginButton = ({ onClick }: KakaoLoginButtonProps) => { return ( - + 카카오로 시작하기 From 8c5570e9b1fb924e761a75aa202324f115bfc9bc Mon Sep 17 00:00:00 2001 From: joojjang Date: Fri, 15 Nov 2024 00:58:19 +0900 Subject: [PATCH 2/9] Merge branch 'Weekly11' --- package.json | 1 + pnpm-lock.yaml | 8 + src/apis/chats/index.ts | 160 +++++++++----- src/apis/chats/useGetChatRoom.ts | 40 ++++ src/apis/chats/usePostChatRoom.ts | 44 ++++ src/apis/data/categories.ts | 36 ++-- src/apis/data/searchAdList.ts | 4 +- src/apis/queryKeys.ts | 1 + src/assets/icons/image.svg | 3 + src/components/common/ArtistItem/index.tsx | 63 ++++-- .../common/CategoryTabBar/index.tsx | 9 +- src/components/common/Chip/index.tsx | 4 +- src/components/common/FakeSearchBar/index.tsx | 1 + src/components/common/ProductItem/index.tsx | 18 +- src/components/common/ProfileImage/index.tsx | 13 +- .../common/SearchModal/RecentSearch.tsx | 5 +- .../common/SearchModal/SearchAd.tsx | 13 +- src/components/common/Thumbnail/index.tsx | 4 +- .../components/CategoryItem/index.tsx | 8 +- src/pages/Categories/index.tsx | 37 ++-- src/pages/My/components/ArtistProfileBox.tsx | 11 +- .../MenuSection/ArtistMenuSection.tsx | 33 ++- .../MenuSection/UserMenuSection.tsx | 31 ++- .../MenuSection/{MenuItem.tsx => styles.ts} | 13 ++ src/pages/My/components/UserProfileBox.tsx | 11 +- src/pages/MyFavorites/index.tsx | 11 +- src/pages/ProductDetails/index.tsx | 43 +++- .../components/ArtWorkContents.tsx | 2 +- .../components/ArtistContents.tsx | 16 +- .../components/HorizontalFrame.tsx | 63 ++++++ .../SearchResults/components/SwiperFrame.tsx | 91 -------- src/pages/SearchResults/index.tsx | 28 +-- .../ChatRoom/components/ChatInput/index.tsx | 202 +++++++++++++++--- .../chats/ChatRoom/components/Date/index.tsx | 13 -- .../ChatRoom/components/MessageItem/index.tsx | 64 +++--- .../ChatRoom/components/MessageList/index.tsx | 77 +++++++ src/pages/chats/ChatRoom/index.tsx | 126 +++++++---- src/routes/index.tsx | 2 +- src/types/chats.ts | 23 ++ src/utils/index.ts | 46 +++- src/utils/queryParams.ts | 11 + 41 files changed, 969 insertions(+), 420 deletions(-) create mode 100644 src/apis/chats/useGetChatRoom.ts create mode 100644 src/apis/chats/usePostChatRoom.ts create mode 100644 src/assets/icons/image.svg rename src/pages/My/components/MenuSection/{MenuItem.tsx => styles.ts} (61%) create mode 100644 src/pages/SearchResults/components/HorizontalFrame.tsx delete mode 100644 src/pages/SearchResults/components/SwiperFrame.tsx delete mode 100644 src/pages/chats/ChatRoom/components/Date/index.tsx create mode 100644 src/pages/chats/ChatRoom/components/MessageList/index.tsx create mode 100644 src/types/chats.ts create mode 100644 src/utils/queryParams.ts diff --git a/package.json b/package.json index 768569bf..e029160d 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "@types/node": "^22.7.3", "@types/sockjs-client": "^1.5.4", "axios": "^1.7.7", + "date-fns": "^4.1.0", "eslint-plugin-import": "^2.31.0", "framer-motion": "^11.9.0", "react": "^18.3.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cd36462e..0a8291ec 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -35,6 +35,9 @@ importers: axios: specifier: ^1.7.7 version: 1.7.7 + date-fns: + specifier: ^4.1.0 + version: 4.1.0 eslint-plugin-import: specifier: ^2.31.0 version: 2.31.0(@typescript-eslint/parser@8.6.0(eslint@9.10.0)(typescript@5.6.2))(eslint@9.10.0) @@ -1614,6 +1617,9 @@ packages: resolution: {integrity: sha512-t/Ygsytq+R995EJ5PZlD4Cu56sWa8InXySaViRzw9apusqsOO2bQP+SbYzAhR0pFKoB+43lYy8rWban9JSuXnA==} engines: {node: '>= 0.4'} + date-fns@4.1.0: + resolution: {integrity: sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==} + debug@3.2.7: resolution: {integrity: sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==} peerDependencies: @@ -4500,6 +4506,8 @@ snapshots: es-errors: 1.3.0 is-data-view: 1.0.1 + date-fns@4.1.0: {} + debug@3.2.7: dependencies: ms: 2.1.3 diff --git a/src/apis/chats/index.ts b/src/apis/chats/index.ts index e552ce15..572bc941 100644 --- a/src/apis/chats/index.ts +++ b/src/apis/chats/index.ts @@ -1,76 +1,136 @@ -import { Client, Stomp } from '@stomp/stompjs'; -import SockJS from 'sockjs-client'; +import { CompatClient } from '@stomp/stompjs'; +// import { Client, CompatClient, Stomp } from '@stomp/stompjs'; +// import SockJS from 'sockjs-client'; + +// import type { ChatMessage } from './types'; export const BASE_URL = import.meta.env.VITE_APP_BASE_URL_CHAT; -export type ChatMessage = { - sender: { email: string }; - content: string; - imageUrl?: string; -}; +// let stompClient: Client | null = null; -// const accessToken = localStorage.getItem('accessToken'); +// /** +// * WebSocket과 서버와의 연결 설정 함수 +// * @param onMessageReceived - 수신된 메시지를 처리할 콜백 함수 +// * @param onError - 에러 시 호출될 콜백 함수 +// */ +// export function connectWebSocket( +// chatRoomId: number, +// onMessageReceived?: (message: ChatMessage) => void, +// onError?: (error: string) => void, +// ): void { +// // WebSocket 연결, STOMP 클라이언트 설정 +// const socket = new SockJS(`${BASE_URL}/ws`); +// stompClient = Stomp.over(() => socket); -let stompClient: Client | null = null; +// // 연결이 열렸을 때 호출될 콜백 함수 +// stompClient.onConnect = () => { +// // SUBSCRIBE +// stompClient?.subscribe(`/v1/sub/chat/rooms/${chatRoomId}`, (message) => { +// console.log('received messages: ', message); -/** - * WebSocket과 서버와의 연결 설정 함수 - * @param onMessageReceived - 수신된 메시지를 처리할 콜백 함수 - * @param onError - 에러 시 호출될 콜백 함수 - */ -export function connectWebSocket( - chatRoomId: number, - onMessageReceived?: (message: ChatMessage) => void, - onError?: (error: string) => void, -): void { - // WebSocket 연결, STOMP 클라이언트 설정 - const socket = new SockJS(`${BASE_URL}/ws`); - stompClient = Stomp.over(socket); +// // const parsedMessage: ChatMessage = JSON.parse(message.body); - // 연결이 열렸을 때 호출될 콜백 함수 - stompClient.onConnect = (frame) => { - console.log('Connected: ' + frame); +// // if (onMessageReceived) { +// // onMessageReceived(parsedMessage); +// // } +// }); +// }; - // 토픽 구독 - stompClient?.subscribe(`/sub/chat/rooms/${chatRoomId}`, (message) => { - const parsedMessage: ChatMessage = JSON.parse(message.body); - - if (onMessageReceived) { - onMessageReceived(parsedMessage); - } - }); +// // 에러 났을 때 호출될 콜백 함수 +// stompClient.onStompError = (errorFrame) => { +// console.error('Broker reported error: ' + errorFrame.headers['message']); - console.log('WebSocket 연결 성공'); - }; +// if (onError) { +// onError(errorFrame.headers['message']); +// } +// }; - // 에러 났을 때 호출될 콜백 함수 - stompClient.onStompError = (errorFrame) => { - console.error('Broker reported error: ' + errorFrame.headers['message']); +// // CONNECT +// stompClient.activate(); +// } - if (onError) { - onError(errorFrame.headers['message']); - } +// 메시지(TEXT) 전송 함수 +export function sendMessage( + stompClient: CompatClient, + chatRoomId: number, + email: string, + content: string, +): void { + const message = { + sender: email, + content, + messageType: 'TEXT', }; - stompClient.activate(); -} - -// 메시지 전송 함수 -export function sendMessage(chatRoomId: number, message: ChatMessage): void { if (stompClient && stompClient.connected) { + // SEND stompClient.publish({ - destination: `/pub/chat/${chatRoomId} `, + destination: `/v1/pub/chat/${chatRoomId}`, body: JSON.stringify(message), + // headers: { receipt: 'message-12345' }, }); } else { - throw new Error('STOMP 클라이언트를 먼저 연결해주세요'); + console.log('STOMP 클라이언트를 먼저 연결해주세요'); + throw new Error('연결이 끊겼습니다. 새로고침 해주세요.'); } } +// 파일 전송 함수 +export async function sendFile( + stompClient: CompatClient, + chatRoomId: number, + email: string, + file: File, +): Promise { + // File을 바이너리 데이터로 변환 + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.readAsArrayBuffer(file); + + reader.onload = () => { + if (stompClient && stompClient.connected && reader.result) { + // const fileBuffer = reader.result as ArrayBuffer; + const fileBytes = new Uint8Array(reader.result as ArrayBuffer); + const fileBase64 = btoa( + fileBytes.reduce((data, byte) => data + String.fromCharCode(byte), ''), + ); + + const message = { + chatRoomId, + userEmail: email, + // fileBytes: fileBuffer, + // fileBytes, + fileBase64: fileBase64, + }; + + try { + stompClient.publish({ + destination: `/v1/pub/chat/${chatRoomId}/file`, + body: JSON.stringify(message), + }); + resolve(); + } catch (error) { + console.error('Failed to send file:', error); + reject(error); + } + } else { + console.log('STOMP 클라이언트를 먼저 연결해주세요'); + reject('연결이 끊겼습니다. 새로고침 해주세요.'); + } + }; + + reader.onerror = (error) => { + console.error('File reading error:', error); + reject(error); + }; + }); +} + // WebSocket 연결 해제 함수 -export function disconnectWebSocket(): void { +export function disconnectWebSocket(stompClient: CompatClient) { if (stompClient) { + // DISCONNECT stompClient.deactivate(); - stompClient = null; + // stompClient.disconnect(); } } diff --git a/src/apis/chats/useGetChatRoom.ts b/src/apis/chats/useGetChatRoom.ts new file mode 100644 index 00000000..4b083732 --- /dev/null +++ b/src/apis/chats/useGetChatRoom.ts @@ -0,0 +1,40 @@ +import { useSuspenseQuery } from '@tanstack/react-query'; +import { isAxiosError } from 'axios'; + +import type { ChatRoom } from '@/types/chats'; +import fetchInstance from '../fetchInstance'; +import QUERY_KEYS from '../queryKeys'; +import { BASE_URL } from './index'; + +type GetChatRoomProps = { + chatRoomId: number; +}; + +type GetChatRoomData = ChatRoom; + +async function getChatRoom({ chatRoomId }: GetChatRoomProps): Promise { + try { + const response = await fetchInstance(BASE_URL).get(`/v1/chat/rooms/${chatRoomId}`); + + return response.data; + } catch (error) { + if (isAxiosError(error)) { + if (error.response) { + throw new Error(error.response.data.message || '채팅방 정보 가져오기 실패'); + } else { + throw new Error('네트워크 오류 또는 서버에 연결할 수 없습니다.'); + } + } else { + throw new Error('알 수 없는 오류입니다.'); + } + } +} + +const useGetChatRoom = (chatRoomId: number) => { + return useSuspenseQuery({ + queryKey: [QUERY_KEYS.CHAT_ROOM, chatRoomId], + queryFn: () => getChatRoom({ chatRoomId }), + }); +}; + +export default useGetChatRoom; diff --git a/src/apis/chats/usePostChatRoom.ts b/src/apis/chats/usePostChatRoom.ts new file mode 100644 index 00000000..bba21ac7 --- /dev/null +++ b/src/apis/chats/usePostChatRoom.ts @@ -0,0 +1,44 @@ +import { useMutation } from '@tanstack/react-query'; +import { isAxiosError } from 'axios'; + +import type { ChatRoom } from '@/types/chats'; +import { getQueryParams } from '@/utils/queryParams'; +import fetchInstance from '../fetchInstance'; +import { BASE_URL } from './index'; + +type PostChatRoomProps = { + userEmail1: string; + userEmail2: string; +}; + +type PostChatRoomData = ChatRoom; + +async function postChatRoom({ + userEmail1, + userEmail2, +}: PostChatRoomProps): Promise { + try { + const queryParams = getQueryParams({ userEmail1: userEmail1, userEmail2: userEmail2 }); + const response = await fetchInstance(BASE_URL).post(`/v1/chat/rooms?${queryParams}`); + + return response.data; + } catch (error) { + if (isAxiosError(error)) { + if (error.response) { + throw new Error(error.response.data.message || '채팅방 생성 실패'); + } else { + throw new Error('네트워크 오류 또는 서버에 연결할 수 없습니다.'); + } + } else { + throw new Error('알 수 없는 오류입니다.'); + } + } +} + +const usePostChatRoom = () => { + return useMutation({ + mutationFn: (props: PostChatRoomProps) => postChatRoom(props), + }); +}; + +export default usePostChatRoom; diff --git a/src/apis/data/categories.ts b/src/apis/data/categories.ts index 3a98a5d8..4c810c06 100644 --- a/src/apis/data/categories.ts +++ b/src/apis/data/categories.ts @@ -3,48 +3,48 @@ import { Categories } from '@/types/index'; const categories: Categories[] = [ { id: 1, - src: 'https://i.pinimg.com/474x/45/37/93/4537932e76ebcc6186f86b75d9eeac87.jpg', - des: '동양화/서양화', + src: 'https://images.unsplash.com/photo-1580136608079-72029d0de130?w=800&auto=format&fit=crop&q=60&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxzZWFyY2h8NHx8JUVCJThGJTk5JUVDJTk2JTkxJUVBJUI3JUI4JUVCJUE2JUJDfGVufDB8fDB8fHww', + des: '동양화/ 한국화', }, { id: 2, - src: 'https://i.pinimg.com/474x/45/37/93/4537932e76ebcc6186f86b75d9eeac87.jpg', - des: '서양화/현대미술', + src: 'https://images.unsplash.com/photo-1579783928621-7a13d66a62d1?w=800&auto=format&fit=crop&q=60&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxzZWFyY2h8Mnx8JUVDJTg0JTlDJUVDJTk2JTkxJUVEJTk5JTk0fGVufDB8fDB8fHww', + des: '서양화', }, { id: 3, - src: 'https://i.pinimg.com/474x/45/37/93/4537932e76ebcc6186f86b75d9eeac87.jpg', - des: '민화/추상화', + src: 'https://plus.unsplash.com/premium_photo-1672287578309-2a2115000688?w=800&auto=format&fit=crop&q=60&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxzZWFyY2h8MXx8JUVDJUExJUIwJUVBJUIwJTgxfGVufDB8fDB8fHww', + des: '조각', }, { id: 4, - src: 'https://i.pinimg.com/474x/45/37/93/4537932e76ebcc6186f86b75d9eeac87.jpg', - des: '동양화/모던아트', + src: 'https://images.unsplash.com/photo-1590605105526-5c08f63f89aa?w=800&auto=format&fit=crop&q=60&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxzZWFyY2h8OHx8JUVCJThGJTg0JUVDJTk4JTg4fGVufDB8fDB8fHww', + des: '도예/공예', }, { id: 5, - src: 'https://i.pinimg.com/474x/45/37/93/4537932e76ebcc6186f86b75d9eeac87.jpg', - des: '서양화/클래식', + src: 'https://images.unsplash.com/photo-1682159672286-40790338349b?w=800&auto=format&fit=crop&q=60&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxzZWFyY2h8MXx8JUVCJTg5JUI0JUVCJUFGJUI4JUVCJTk0JTk0JUVDJTk2JUI0fGVufDB8fDB8fHww', + des: '뉴미디어', }, { id: 6, - src: 'https://i.pinimg.com/474x/45/37/93/4537932e76ebcc6186f86b75d9eeac87.jpg', - des: '수묵화/풍경화', + src: 'https://plus.unsplash.com/premium_photo-1663100678842-d89cb7b084ee?w=800&auto=format&fit=crop&q=60&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxzZWFyY2h8MTd8fCVFQiU5NCU5NCVFQyU5RSU5MCVFQyU5RCVCOHxlbnwwfHwwfHx8MA%3D%3D', + des: '디자인', }, { id: 7, - src: 'https://i.pinimg.com/474x/45/37/93/4537932e76ebcc6186f86b75d9eeac87.jpg', - des: '동양화/인물화', + src: 'https://plus.unsplash.com/premium_photo-1723075214781-ea091374d81c?w=800&auto=format&fit=crop&q=60&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxzZWFyY2h8MXx8JUVEJThDJTkwJUVEJTk5JTk0fGVufDB8fDB8fHww', + des: '드로잉/판화', }, { id: 8, - src: 'https://i.pinimg.com/474x/45/37/93/4537932e76ebcc6186f86b75d9eeac87.jpg', - des: '서양화/정물화', + src: 'https://images.unsplash.com/photo-1506434304575-afbb92660c28?w=800&auto=format&fit=crop&q=60&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxzZWFyY2h8Nnx8JUVDJTgyJUFDJUVDJUE3JTg0JUVDJTgyJUFDfGVufDB8fDB8fHww', + des: '사진', }, { id: 9, - src: 'https://i.pinimg.com/474x/45/37/93/4537932e76ebcc6186f86b75d9eeac87.jpg', - des: '동양화/서양화', + src: 'https://images.unsplash.com/photo-1546638008-efbe0b62c730?w=800&auto=format&fit=crop&q=60&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxzZWFyY2h8OHx8JUVCJThGJTk5JUVDJTk2JTkxJUVBJUI3JUI4JUVCJUE2JUJDfGVufDB8fDB8fHww', + des: '서예/캘리그라피', }, ]; diff --git a/src/apis/data/searchAdList.ts b/src/apis/data/searchAdList.ts index 67177045..11a08f97 100644 --- a/src/apis/data/searchAdList.ts +++ b/src/apis/data/searchAdList.ts @@ -3,11 +3,11 @@ import { SearchAd } from '@/types/index'; const searchAdList: SearchAd[] = [ { id: 1, - src: 'https://i.pinimg.com/474x/45/37/93/4537932e76ebcc6186f86b75d9eeac87.jpg', + src: ' https://images.unsplash.com/photo-1579273166152-d725a4e2b755?w=800&auto=format&fit=crop&q=60&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxzZWFyY2h8MTl8fCVFQSVCNyVCOCVFQiVBNiVCQ3xlbnwwfHwwfHx8MA%3D%3D', }, { id: 2, - src: 'https://i.pinimg.com/474x/45/37/93/4537932e76ebcc6186f86b75d9eeac87.jpg', + src: 'https://images.unsplash.com/photo-1577083862054-7324cd025fa6?w=800&auto=format&fit=crop&q=60&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxzZWFyY2h8MTR8fCVFQyU4NCU5QyVFQyU5NiU5MSVFRCU5OSU5NHxlbnwwfHwwfHx8MA%3D%3D', }, ]; diff --git a/src/apis/queryKeys.ts b/src/apis/queryKeys.ts index d198cef4..f6d243b8 100644 --- a/src/apis/queryKeys.ts +++ b/src/apis/queryKeys.ts @@ -3,6 +3,7 @@ const QUERY_KEYS = { FEED: 'feed', FOLLOW_LIST: 'followList', USER_INFO: 'userInfo', + CHAT_ROOM: 'chatRoom', }; export default QUERY_KEYS; diff --git a/src/assets/icons/image.svg b/src/assets/icons/image.svg new file mode 100644 index 00000000..95498dde --- /dev/null +++ b/src/assets/icons/image.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/src/components/common/ArtistItem/index.tsx b/src/components/common/ArtistItem/index.tsx index 9dbd15fa..800905ba 100644 --- a/src/components/common/ArtistItem/index.tsx +++ b/src/components/common/ArtistItem/index.tsx @@ -1,44 +1,62 @@ import styled from '@emotion/styled'; +import useDeleteFollow from '@/apis/users/useDeleteFollow'; +import usePostFollow from '@/apis/users/usePostFollow'; import FollowButton from '@/components/common/FollowButton'; import Thumbnail from '@/components/common/Thumbnail'; import { useState } from 'react'; import LikesAndFollowers from '../LikesAndFollowers'; -interface ArtistItemProps { +type ArtistItemProps = { + artistId: number; author: string; like: number; follower: number; - size?: 'large' | 'default'; src?: string; alt?: string; - onFollow?: () => void; isFollow: boolean; -} +}; -const ArtistItem = ({ - author, - like, - follower, - size = 'default', - src, - alt, - onFollow, - isFollow, -}: ArtistItemProps) => { +const ArtistItem = ({ artistId, author, like, follower, src, alt, isFollow }: ArtistItemProps) => { const [isFollowed, setIsFollowed] = useState(isFollow); + const { mutate: postFollow, status: isPostStatus } = usePostFollow(); + const { mutate: deleteFollow, status: isDeleteStatus } = useDeleteFollow(); + const handleFollowClick = () => { - setIsFollowed(!isFollowed); - onFollow?.(); + if (isFollowed) { + deleteFollow(artistId, { + onSuccess: () => { + setIsFollowed(false); + }, + onError: (error) => { + console.error('Failed to delete follow:', error); + alert('팔로우 취소에 실패했습니다.'); + }, + }); + } else { + postFollow(artistId, { + onSuccess: () => { + setIsFollowed(true); + }, + onError: (error) => { + console.error('Failed to post follow:', error); + alert('팔로우에 실패했습니다.'); + }, + }); + } }; return ( - +

{author}

- + {isFollowed ? '팔로잉' : '팔로우'}
@@ -49,9 +67,10 @@ const ArtistItem = ({ export default ArtistItem; -const Wrapper = styled.div<{ size: 'large' | 'default' }>` - width: ${({ size }) => (size === 'large' ? '15.8rem' : '14rem')}; - height: ${({ size }) => (size === 'large' ? '22.5em' : '20.7em')}; +const Wrapper = styled.div` + width: 100%; + height: 100%; + max-width: 170px; background-color: var(--color-white); `; @@ -60,6 +79,6 @@ const MidWrapper = styled.div` align-items: center; justify-content: space-between; width: 100%; - height: 1.7rem; + height: 17px; margin: 0.8rem 0; `; diff --git a/src/components/common/CategoryTabBar/index.tsx b/src/components/common/CategoryTabBar/index.tsx index 4c503b55..a90d075f 100644 --- a/src/components/common/CategoryTabBar/index.tsx +++ b/src/components/common/CategoryTabBar/index.tsx @@ -1,3 +1,4 @@ +import { Z_INDEX } from '@/styles/constants'; import styled from '@emotion/styled'; import { useEffect, useState } from 'react'; @@ -39,12 +40,13 @@ const CategoryTabBar = ({ tabClick, tabState, tabList }: CategoryTabBarProps) => export default CategoryTabBar; const Wrapper = styled.div` + z-index: ${Z_INDEX.Header}; + position: fixed; width: 100%; - height: 44px; + height: 41px; display: flex; flex-direction: row; justify-content: space-between; - padding: 0px 16px; align-items: center; border-bottom: 1px solid var(--color-gray-md); background: var(--color-white); @@ -54,7 +56,8 @@ const Wrapper = styled.div` const TabWrapper = styled.div` width: 100%; - padding: 11px 58px; + height: 100%; + padding: 11px; cursor: pointer; text-align: center; diff --git a/src/components/common/Chip/index.tsx b/src/components/common/Chip/index.tsx index 02bf8549..c42bb8a7 100644 --- a/src/components/common/Chip/index.tsx +++ b/src/components/common/Chip/index.tsx @@ -1,6 +1,7 @@ import styled from '@emotion/styled'; import CancelDefault from '@/assets/icons/cancel-default.svg?react'; +import { useNavigate } from 'react-router-dom'; interface ChipProps { tag: string; @@ -8,9 +9,10 @@ interface ChipProps { } const Chip = ({ tag, onClick }: ChipProps) => { + const navigate = useNavigate(); return ( - {tag} + diff --git a/src/components/common/FakeSearchBar/index.tsx b/src/components/common/FakeSearchBar/index.tsx index 8d692bd9..5a270939 100644 --- a/src/components/common/FakeSearchBar/index.tsx +++ b/src/components/common/FakeSearchBar/index.tsx @@ -35,6 +35,7 @@ const SearchBarWrapper = styled.div` align-items: center; gap: 10px; cursor: pointer; + background-color: var(--color-white); `; const InputBox = styled.div` diff --git a/src/components/common/ProductItem/index.tsx b/src/components/common/ProductItem/index.tsx index d377f2ff..552599d1 100644 --- a/src/components/common/ProductItem/index.tsx +++ b/src/components/common/ProductItem/index.tsx @@ -2,19 +2,18 @@ import styled from '@emotion/styled'; import Thumbnail from '@/components/common/Thumbnail'; -interface ArtistItemProps { +type ArtistItemProps = { author: string; title: string; price: number; - size?: 'large' | 'default'; heart?: boolean; src?: string; alt?: string; -} +}; -const ProductItem = ({ author, title, price, size = 'default', src, alt }: ArtistItemProps) => { +const ProductItem = ({ author, title, price, src, alt }: ArtistItemProps) => { return ( - + {author} @@ -27,9 +26,10 @@ const ProductItem = ({ author, title, price, size = 'default', src, alt }: Artis export default ProductItem; -const Wrapper = styled.div<{ size: 'large' | 'default' }>` - width: ${({ size }) => (size === 'large' ? '15.8rem' : '14rem')}; - height: ${({ size }) => (size === 'large' ? '24.1em' : '22.3em')}; +const Wrapper = styled.div` + width: 100%; + height: 100%; + max-width: 170px; background-color: var(--color-white); `; @@ -39,7 +39,7 @@ const MidWrapper = styled.div` align-items: flex-start; justify-content: space-between; width: 100%; - height: 1.7rem; + height: 60px; margin: 0.8rem 0; `; diff --git a/src/components/common/ProfileImage/index.tsx b/src/components/common/ProfileImage/index.tsx index 6cad8ad4..55fec96a 100644 --- a/src/components/common/ProfileImage/index.tsx +++ b/src/components/common/ProfileImage/index.tsx @@ -1,8 +1,16 @@ import styled from '@emotion/styled'; -const ProfileImage = ({ width, imageUrl }: { width: number; imageUrl?: string }) => ( +const ProfileImage = ({ + width, + imageUrl, + alt, +}: { + width: number; + imageUrl?: string; + alt: string; +}) => ( - + {alt} ); @@ -12,6 +20,7 @@ const StyledProfileImage = styled.div<{ width: number }>` width: ${({ width }) => `${width}px`}; aspect-ratio: 1 / 1; border-radius: 50px; + overflow: hidden; border: 1px solid var(--color-gray-md); background-color: var(--color-gray-lt); diff --git a/src/components/common/SearchModal/RecentSearch.tsx b/src/components/common/SearchModal/RecentSearch.tsx index 2edbe5f3..c6a12a75 100644 --- a/src/components/common/SearchModal/RecentSearch.tsx +++ b/src/components/common/SearchModal/RecentSearch.tsx @@ -1,7 +1,7 @@ +import { Text } from '@chakra-ui/react'; import styled from '@emotion/styled'; +import { useEffect, useState } from 'react'; import Chip from '../Chip'; -import { useState, useEffect } from 'react'; -import { Text } from '@chakra-ui/react'; export const SEARCH_ARRAY_KEY = 'searchArray'; @@ -47,6 +47,7 @@ const Wrapper = styled.div` display: flex; flex-direction: column; padding: 16px; + margin-top: 41px; `; const ChipWrapper = styled.div` diff --git a/src/components/common/SearchModal/SearchAd.tsx b/src/components/common/SearchModal/SearchAd.tsx index 574d5171..882327cb 100644 --- a/src/components/common/SearchModal/SearchAd.tsx +++ b/src/components/common/SearchModal/SearchAd.tsx @@ -1,8 +1,8 @@ import styled from '@emotion/styled'; -import { Image } from '@chakra-ui/react'; -import Grid from '@/components/styles/Grid'; import searchAdList from '@/apis/data/searchAdList'; +import Grid from '@/components/styles/Grid'; +import { Image } from '@chakra-ui/react'; const SearchAd = () => { return ( @@ -15,7 +15,7 @@ const SearchAd = () => { {searchAdList.map((ad) => ( - + ))} @@ -48,7 +48,12 @@ const RedText = styled.span` `; const AdImage = styled(Image)` - width: 158px; + width: 100%; + height: 100%; + aspect-ratio: 1 / 1; + max-width: 200px; + max-height: 200px; + background-color: var(--color-gray-lt); `; const Tab = styled.p` diff --git a/src/components/common/Thumbnail/index.tsx b/src/components/common/Thumbnail/index.tsx index 65852bee..645f4bf2 100644 --- a/src/components/common/Thumbnail/index.tsx +++ b/src/components/common/Thumbnail/index.tsx @@ -52,8 +52,8 @@ const StyledImage = styled(Image)` const FavoriteWrapper = styled.div` position: absolute; - top: 110px; - right: 8px; + top: 80%; + right: 5%; width: 24px; height: 24px; cursor: pointer; diff --git a/src/pages/Categories/components/CategoryItem/index.tsx b/src/pages/Categories/components/CategoryItem/index.tsx index 3cb4cbd8..07172d9c 100644 --- a/src/pages/Categories/components/CategoryItem/index.tsx +++ b/src/pages/Categories/components/CategoryItem/index.tsx @@ -11,7 +11,7 @@ const CategoryItem = ({ des, src }: CategoryItemProps) => { return ( - + {des} ); @@ -28,9 +28,11 @@ const Wrapper = styled.div` `; const RoundImage = styled(Image)` - width: 6.4rem; - height: 6.4rem; + aspect-ratio: 1/1; + width: 80%; border-radius: 100%; + object-fit: cover; + background-color: var(--color-gray-lt); `; const DesWrapper = styled.p` diff --git a/src/pages/Categories/index.tsx b/src/pages/Categories/index.tsx index e47a759e..68a6c25a 100644 --- a/src/pages/Categories/index.tsx +++ b/src/pages/Categories/index.tsx @@ -2,12 +2,12 @@ import { Text } from '@chakra-ui/react'; import styled from '@emotion/styled'; import categories from '@/apis/data/categories'; +import FakeSearchBar from '@/components/common/FakeSearchBar'; +import SearchModal from '@/components/common/SearchModal'; import Gap from '@/components/styles/Gap'; import Grid from '@/components/styles/Grid'; -import Category from './components/CategoryItem'; import { useState } from 'react'; -import FakeSearchBar from '@/components/common/FakeSearchBar'; -import SearchModal from '@/components/common/SearchModal'; +import Category from './components/CategoryItem'; const Categories = () => { const [isModalOpen, setIsModalOpen] = useState(false); @@ -20,22 +20,23 @@ const Categories = () => { {isModalOpen && setIsModalOpen(false)} />} - {categories.map((category) => ( ))} - - 매거진 - 숨겨진 무한의 가치를 발견하고 싶다면 - - - - 아티스트 그라운드 - 내 취향대로 작가 골라보기 - + + + 매거진 + 숨겨진 무한의 가치를 발견하고 싶다면 + + + + 아티스트 그라운드 + 내 취향대로 작가 골라보기 + + ); }; @@ -48,17 +49,27 @@ const Wrapper = styled.div` const CurationItem = styled.div` display: flex; + flex-direction: row; align-items: center; width: 100%; padding: 16px; + min-height: 54px; gap: 8px; `; const Title = styled(Text)` font-size: var(--font-size-md); font-weight: 600; + line-height: 1.2; // 줄 높이 추가 `; const Des = styled(Text)` font-size: var(--font-size-sm); + line-height: 1.2; // 줄 높이 추가 +`; + +const CurationWrapper = styled.div` + height: auto; + width: 100%; + margin-bottom: 54px; `; diff --git a/src/pages/My/components/ArtistProfileBox.tsx b/src/pages/My/components/ArtistProfileBox.tsx index da517f5d..acc85f87 100644 --- a/src/pages/My/components/ArtistProfileBox.tsx +++ b/src/pages/My/components/ArtistProfileBox.tsx @@ -1,4 +1,5 @@ import LikesAndFollowers from '@/components/common/LikesAndFollowers'; +import ProfileImage from '@/components/common/ProfileImage'; import { ArtistInfo } from '@/types'; import styled from '@emotion/styled'; @@ -12,7 +13,7 @@ const ArtistProfileBox = ({ }: ArtistInfo) => { return ( - {nickname} + {nickname} @@ -40,14 +41,6 @@ const Wrapper = styled.div` background: var(--color-white); `; -const Image = styled.img` - width: 96px; - height: 96px; - flex-shrink: 0; - border-radius: 50%; - border: 1px solid var(--color-gray-md); -`; - const DetailWrapper = styled.div` display: flex; height: 71px; diff --git a/src/pages/My/components/MenuSection/ArtistMenuSection.tsx b/src/pages/My/components/MenuSection/ArtistMenuSection.tsx index d1c58220..ebd24b97 100644 --- a/src/pages/My/components/MenuSection/ArtistMenuSection.tsx +++ b/src/pages/My/components/MenuSection/ArtistMenuSection.tsx @@ -1,33 +1,28 @@ import { RouterPath } from '@/routes/path'; -import styled from '@emotion/styled'; import { useNavigate } from 'react-router-dom'; -import { MenuItem } from './MenuItem'; +import { MenuItem, UlWrapper, Wrapper } from './styles'; + +const menuItems = [ + { label: '판매 내역', path: RouterPath.sales }, + { label: '찜 / 팔로우', path: RouterPath.favorites }, + { label: '내 갤러리', path: RouterPath.gallery }, + { label: '회원 정보 수정', path: '' }, +]; const ArtistMenuSection = () => { const navigate = useNavigate(); + return ( - navigate(RouterPath.sales)}>판매 내역 - navigate(RouterPath.favorites)}>찜 / 팔로우 - navigate(RouterPath.gallery)}>내 갤러리 - 회원 정보 수정 + {menuItems.map((item, index) => ( + item.path && navigate(item.path)}> + {item.label} + + ))} ); }; export default ArtistMenuSection; - -const Wrapper = styled.div` - width: 100%; - display: flex; - padding: 0px 16px; - flex-direction: column; - align-items: center; - align-self: stretch; -`; - -const UlWrapper = styled.ul` - width: 100%; -`; diff --git a/src/pages/My/components/MenuSection/UserMenuSection.tsx b/src/pages/My/components/MenuSection/UserMenuSection.tsx index 7e71857c..d5012391 100644 --- a/src/pages/My/components/MenuSection/UserMenuSection.tsx +++ b/src/pages/My/components/MenuSection/UserMenuSection.tsx @@ -1,32 +1,27 @@ import { RouterPath } from '@/routes/path'; -import styled from '@emotion/styled'; import { useNavigate } from 'react-router-dom'; -import { MenuItem } from './MenuItem'; +import { MenuItem, UlWrapper, Wrapper } from './styles'; + +const menuItems = [ + { label: '구매 내역', path: RouterPath.orders }, + { label: '찜 / 팔로우', path: RouterPath.favorites }, + { label: '회원 정보 수정', path: '' }, +]; const UserMenuSection = () => { const navigate = useNavigate(); + return ( - navigate(RouterPath.orders)}>구매 내역 - navigate(RouterPath.favorites)}>찜 / 팔로우 - 회원 정보 수정 + {menuItems.map((item, index) => ( + item.path && navigate(item.path)}> + {item.label} + + ))} ); }; export default UserMenuSection; - -const Wrapper = styled.div` - width: 100%; - display: flex; - padding: 0px 16px; - flex-direction: column; - align-items: center; - align-self: stretch; -`; - -const UlWrapper = styled.ul` - width: 100%; -`; diff --git a/src/pages/My/components/MenuSection/MenuItem.tsx b/src/pages/My/components/MenuSection/styles.ts similarity index 61% rename from src/pages/My/components/MenuSection/MenuItem.tsx rename to src/pages/My/components/MenuSection/styles.ts index 62402f81..5b055298 100644 --- a/src/pages/My/components/MenuSection/MenuItem.tsx +++ b/src/pages/My/components/MenuSection/styles.ts @@ -1,5 +1,18 @@ import styled from '@emotion/styled'; +export const Wrapper = styled.div` + width: 100%; + display: flex; + padding: 0px 16px; + flex-direction: column; + align-items: center; + align-self: stretch; +`; + +export const UlWrapper = styled.ul` + width: 100%; +`; + export const MenuItem = styled.li` display: flex; padding: 12px 0px; diff --git a/src/pages/My/components/UserProfileBox.tsx b/src/pages/My/components/UserProfileBox.tsx index a0f0d739..01c0d33f 100644 --- a/src/pages/My/components/UserProfileBox.tsx +++ b/src/pages/My/components/UserProfileBox.tsx @@ -1,10 +1,11 @@ +import ProfileImage from '@/components/common/ProfileImage'; import { UserInfo } from '@/types'; import styled from '@emotion/styled'; const UserProfileBox = ({ userImageUrl, hashTags, username }: UserInfo) => { return ( - {username} + {username} @@ -35,14 +36,6 @@ const Wrapper = styled.div` background: var(--color-white); `; -const Image = styled.img` - width: 96px; - height: 96px; - flex-shrink: 0; - border-radius: 50%; - border: 1px solid var(--color-gray-md); -`; - const DetailWrapper = styled.div` display: flex; height: 71px; diff --git a/src/pages/MyFavorites/index.tsx b/src/pages/MyFavorites/index.tsx index 35985e27..371e8328 100644 --- a/src/pages/MyFavorites/index.tsx +++ b/src/pages/MyFavorites/index.tsx @@ -3,6 +3,7 @@ import ArtistItem from '@/components/common/ArtistItem'; import CategoryTabBar from '@/components/common/CategoryTabBar'; import Grid from '@/components/styles/Grid'; import { User } from '@/types'; +import styled from '@emotion/styled'; import { useEffect, useState } from 'react'; const MyFavorites = () => { @@ -30,9 +31,9 @@ const MyFavorites = () => { <> {selectedTab === '작품' ? ( -
작품
// 현재 이부분 api가 없어 비워두었습니다. + 작품 // 현재 이부분 api가 없어 비워두었습니다. ) : ( -
+ {data?.data.content?.length === 0 ? (

팔로우한 작가가 없습니다.

) : ( @@ -48,10 +49,14 @@ const MyFavorites = () => { ))} )} -
+
)} ); }; export default MyFavorites; + +const Wrapper = styled.div` + margin-top: 41px; +`; diff --git a/src/pages/ProductDetails/index.tsx b/src/pages/ProductDetails/index.tsx index 0df54937..daf5bd5c 100644 --- a/src/pages/ProductDetails/index.tsx +++ b/src/pages/ProductDetails/index.tsx @@ -1,5 +1,46 @@ +import { useNavigate } from 'react-router-dom'; + +import usePostChatRoom from '@/apis/chats/usePostChatRoom'; +import CTA, { CTAContainer } from '@/components/common/CTA'; +import useUserStore from '@/store/useUserStore'; +import { RouterPath } from '@/routes/path'; + +const USER_EMAIL_1 = 'ble6859@knu.ac.kr'; +const USER_EMAIL_2 = 'user2@example.com'; + const ProductDetails = () => { - return <>ProductDetails; + const { email } = useUserStore(); + const userEmail1 = email || USER_EMAIL_1; // 사용자 본인 이메일 + const userEmail2 = USER_EMAIL_2; // 상대방 이메일 + const navigate = useNavigate(); + + const { mutate: postChatRoom } = usePostChatRoom(); + + const handleClickChat = () => { + postChatRoom( + { + userEmail1, + userEmail2, + }, + { + onSuccess: (data) => { + const chatRoomId = data.id; + navigate(`${RouterPath.chats}/${chatRoomId}`); + }, + onError: (error) => { + alert(error); + }, + }, + ); + }; + + return ( + <> + + + + + ); }; export default ProductDetails; diff --git a/src/pages/SearchResults/components/ArtWorkContents.tsx b/src/pages/SearchResults/components/ArtWorkContents.tsx index bb4c930f..c9ed7d3d 100644 --- a/src/pages/SearchResults/components/ArtWorkContents.tsx +++ b/src/pages/SearchResults/components/ArtWorkContents.tsx @@ -42,7 +42,7 @@ const ArtWorkContents = () => { return (
- {searchWorkLen}점의 작품{' '} + {searchWorkLen}점의 작품 isOpen={isOpen} selectedOption={selectedOption} diff --git a/src/pages/SearchResults/components/ArtistContents.tsx b/src/pages/SearchResults/components/ArtistContents.tsx index c122716b..dac29218 100644 --- a/src/pages/SearchResults/components/ArtistContents.tsx +++ b/src/pages/SearchResults/components/ArtistContents.tsx @@ -1,5 +1,4 @@ import searchArtist from '@/apis/data/searchArtist'; -import usePostFollow from '@/apis/users/usePostFollow'; import ArtistItem from '@/components/common/ArtistItem'; import Grid from '@/components/styles/Grid'; import { SearchArtist } from '@/types'; @@ -10,8 +9,6 @@ import DropdownButton from './Dropdown'; export type ArtistOptions = '최신순' | '인기순' | '이름순' | '팔로우순'; const ArtistContents = () => { - const { mutate: postFollow } = usePostFollow(); - const searchArtistLen = searchArtist.length; const originalSearchArtist = useRef(searchArtist); @@ -21,10 +18,6 @@ const ArtistContents = () => { const options: ArtistOptions[] = ['최신순', '인기순', '이름순', '팔로우순']; - const handleFollow = (artistId: number) => { - postFollow(artistId); - }; - const handleOpen = () => { setIsOpen(!isOpen); }; @@ -53,7 +46,7 @@ const ArtistContents = () => { return (
- {searchArtistLen}명의 작가{' '} + {searchArtistLen}명의 작가 isOpen={isOpen} selectedOption={selectedOption} @@ -65,12 +58,12 @@ const ArtistContents = () => { {sortedArtist.map((item) => ( handleFollow(item.id)} isFollow={item.followed} /> ))} @@ -82,8 +75,8 @@ const ArtistContents = () => { export default ArtistContents; const ResultWrapper = styled.div` - color: var(--color-black, #020715); - font-size: 1.4rem; + color: var(--color-black); + font-size: var(--font-size-sm); font-style: normal; font-weight: 600; line-height: normal; @@ -91,5 +84,6 @@ const ResultWrapper = styled.div` flex-direction: row; padding: 8px 16px; justify-content: space-between; + align-items: center; width: 100%; `; diff --git a/src/pages/SearchResults/components/HorizontalFrame.tsx b/src/pages/SearchResults/components/HorizontalFrame.tsx new file mode 100644 index 00000000..f8264639 --- /dev/null +++ b/src/pages/SearchResults/components/HorizontalFrame.tsx @@ -0,0 +1,63 @@ +import ArtistItem from '@/components/common/ArtistItem'; +import ProductItem from '@/components/common/ProductItem'; +import { SearchArtist, SearchWork } from '@/types/index'; +import styled from '@emotion/styled'; + +interface HorizontalFrameProps { + children: SearchArtist[] | SearchWork[]; +} + +const HorizontalFrame = ({ children }: HorizontalFrameProps) => { + return ( + + {children.map((item) => ( + + {'title' in item && ( + + )} + {'name' in item && ( + + )} + + ))} + + ); +}; + +export default HorizontalFrame; + +const HorizontalScrollWrapper = styled.div` + display: flex; + overflow-x: scroll; + white-space: nowrap; + padding: 0 16px; + gap: 16px; + -webkit-overflow-scrolling: touch; + width: 100%; + height: 100%; + &::-webkit-scrollbar { + display: none; + } +`; + +const StyledItemWrapper = styled.div` + flex-shrink: 0; + width: 180px; + height: 100%; +`; diff --git a/src/pages/SearchResults/components/SwiperFrame.tsx b/src/pages/SearchResults/components/SwiperFrame.tsx deleted file mode 100644 index 867b5cb9..00000000 --- a/src/pages/SearchResults/components/SwiperFrame.tsx +++ /dev/null @@ -1,91 +0,0 @@ -import usePostFollow from '@/apis/users/usePostFollow'; -import ArtistItem from '@/components/common/ArtistItem'; -import ProductItem from '@/components/common/ProductItem'; -import { SearchArtist, SearchWork } from '@/types/index'; -import { Navigation, Scrollbar } from 'swiper/modules'; -import { Swiper, SwiperSlide } from 'swiper/react'; - -import styled from '@emotion/styled'; - -import 'swiper/css'; -import 'swiper/css/navigation'; -import 'swiper/css/scrollbar'; - -interface SwiperFrame { - children: SearchArtist[] | SearchWork[]; -} - -const SwiperFrame = ({ children }: SwiperFrame) => { - const { mutate: postFollow } = usePostFollow(); - - const handleFollow = (artistId: number) => { - postFollow(artistId); - }; - - return ( - - {children.map((item) => ( - - {'title' in item && ( - - )} - {'name' in item && ( - handleFollow(item.id)} - isFollow={item.followed} - /> - )} - - ))} - - ); -}; - -export default SwiperFrame; - -const SwiperWrapper = styled(Swiper)` - width: 100%; - height: 223px; - padding: 0 16px; - .swiper-button-next, - .swiper-button-prev { - color: #333; - border-radius: 50%; - width: 20px; - height: 20px; - padding: 5px; - - &:hover { - color: var(--color-white); - } - } - - .swiper-button-next { - right: 10px; - } - - .swiper-button-prev { - left: 10px; - } -`; - -const StyledSwiperSlide = styled(SwiperSlide)` - width: 180px; - height: 144px; -`; diff --git a/src/pages/SearchResults/index.tsx b/src/pages/SearchResults/index.tsx index cea7af95..82da5e4b 100644 --- a/src/pages/SearchResults/index.tsx +++ b/src/pages/SearchResults/index.tsx @@ -1,3 +1,4 @@ +import { Z_INDEX } from '@/styles/constants'; import styled from '@emotion/styled'; import { useState } from 'react'; import { useNavigate } from 'react-router-dom'; @@ -8,11 +9,10 @@ import CategoryTabBar from '@/components/common/CategoryTabBar'; import SearchBar from '@/components/layouts/SearchBar'; import Gap from '@/components/styles/Gap'; import { RouterPath } from '@/routes/path'; -import { Z_INDEX } from '@/styles/constants'; import ArtWorkContents from './components/ArtWorkContents'; import ArtistContents from './components/ArtistContents'; +import HorizontalFrame from './components/HorizontalFrame'; import MoreButton from './components/MoreButton'; -import SwiperFrame from './components/SwiperFrame'; const SearchResults = () => { const [selectedTab, setSelectedTab] = useState('전체'); @@ -34,8 +34,8 @@ const SearchResults = () => { - + {selectedTab === '전체' && ( @@ -45,10 +45,10 @@ const SearchResults = () => { 작품 ({searchWorkLen}) - - + + handleTabClick('작품')}> 더보기 - + @@ -57,10 +57,10 @@ const SearchResults = () => { 작가 ({searchArtistLen}) - - + + handleTabClick('작가')}> 더보기 - + )} @@ -73,18 +73,20 @@ const SearchResults = () => { export default SearchResults; -const PageContainer = styled.div``; +const PageContainer = styled.div` + width: 100%; +`; const HeaderSection = styled.div` position: sticky; - top: 0; + height: 41px; z-index: ${Z_INDEX.SearchHeader}; - background: var(--color-white); `; const ContentSection = styled.div` flex: 1; overflow-y: auto; + margin-top: 41px; `; const AllContentWrapper = styled.div` @@ -122,7 +124,7 @@ const ResultLightFont = styled.div` margin-left: 2px; `; -const SwiperWrapper = styled.div` +const HorizontalWRapper = styled.div` display: flex; flex-direction: column; justify-content: center; diff --git a/src/pages/chats/ChatRoom/components/ChatInput/index.tsx b/src/pages/chats/ChatRoom/components/ChatInput/index.tsx index 7758c0a2..2d4a6a40 100644 --- a/src/pages/chats/ChatRoom/components/ChatInput/index.tsx +++ b/src/pages/chats/ChatRoom/components/ChatInput/index.tsx @@ -1,22 +1,31 @@ import styled from '@emotion/styled'; +import { CompatClient } from '@stomp/stompjs'; import { useRef, useState } from 'react'; -import { sendMessage } from '@/apis/chats'; +import { sendFile, sendMessage } from '@/apis/chats'; +import CancelIcon from '@/assets/icons/cancel-default.svg?react'; +import ImageIcon from '@/assets/icons/image.svg?react'; import SendIcon from '@/assets/icons/send.svg?react'; +import type { User } from '@/types/chats'; import { countNonSpaceChars } from '@/utils'; type ChatInputProps = { + client: CompatClient | null; chatRoomId: number; - userEmail: string; + sender: User; onHeightChange: (height: string) => void; }; -const ChatInput = ({ chatRoomId, userEmail, onHeightChange }: ChatInputProps) => { - const [message, setMessage] = useState(''); - +const ChatInput = ({ client, chatRoomId, sender, onHeightChange }: ChatInputProps) => { + // 메시지 인풋 창 높이 조정 const textareaRef = useRef(null); const [chatInputHeight, setChatInputHeight] = useState('5.4rem'); + // SEND DTO + const [content, setContent] = useState(''); + const fileRef = useRef(null); + const [image, setImage] = useState(null); + // 내용의 세로 길이에 맞게 입력창 높이 자동 조정하는 함수 const adjustHeight = (textarea: HTMLTextAreaElement) => { textarea.style.height = 'auto'; // 초기화 @@ -27,39 +36,152 @@ const ChatInput = ({ chatRoomId, userEmail, onHeightChange }: ChatInputProps) => onHeightChange(`${newHeight + 12}px`); // 부모로 높이 전달 (패딩 포함) }; - const handleInput = (e: React.ChangeEvent) => { - setMessage(e.target.value); + // 텍스트 컨텐트 핸들러 + const handleTextContent = (e: React.ChangeEvent) => { + setContent(e.target.value); adjustHeight(e.target); }; - // 메시지 전송 핸들러 - const handleSendMessage = () => { - if (message && message.trim()) { - try { - sendMessage(chatRoomId, { - sender: { email: userEmail }, - content: message, - imageUrl: undefined, - }); - - setMessage(''); - } catch (error) { - alert(error); + // 메시지(TEXT) 전송 핸들러 + const handleSendText = async () => { + if (!client || !content || content.trim() === '') { + return; + } + + try { + // 파라미터: client, chatRoomId, email, content + sendMessage(client, chatRoomId, sender.email, content); + setContent(''); + } catch (error) { + alert(error); + } + }; + + // 이미지 선택 핸들러 + const handleUploadImage = () => { + fileRef.current?.click(); + }; + + // 이미지 미리보기 + const handleImageChange = (e: React.ChangeEvent) => { + const fileBlob = e.target.files ? e.target.files[0] : null; + setImage(fileBlob); // File 객체를 직접 설정합니다. + + // 미리보기를 위해 FileReader를 사용합니다. + if (fileBlob) { + const reader = new FileReader(); + reader.readAsDataURL(fileBlob); + + reader.onload = () => { + if (reader.result && typeof reader.result === 'string') { + // 여기서 미리보기 이미지 URL을 설정합니다. + const imgElement = document.querySelector('#previewImage') as HTMLImageElement; + if (imgElement) { + imgElement.src = reader.result; + } + } + }; + } + }; + + // 이미지 삭제 핸들러 + const handleDeleteImage = (e: React.MouseEvent) => { + e.preventDefault(); + setImage(null); + }; + + // 메시지(IMAGE) 전송 핸들러 + const handleSendImage = async () => { + if (!client || !image) { + return; + } + + try { + // 파라미터: client, chatRoomId, email, file + sendFile(client, chatRoomId, sender.email, image); + setImage(null); + } catch (error) { + alert(error); + } + + // // 이미지 전송 test + // const reader = new FileReader(); + + // reader.onload = function (e) { + // const fileBytes = new Uint8Array(e.target?.result as ArrayBuffer); + // const fileBase64 = btoa( + // fileBytes.reduce((data, byte) => data + String.fromCharCode(byte), ''), + // ); + + // if (fileBase64) { + // const message = { + // chatRoomId, + // userEmail: sender.email, + // fileBase64, + // }; + + // client.send(JSON.stringify(message)); + // } + // }; + + // reader.readAsArrayBuffer(image); // 파일을 ArrayBuffer로 읽음 + }; + + // 키보드 이벤트 핸들러 + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + if (e.shiftKey) { + // shift + enter는 줄바꿈 + return; } + // enter만 누르면 메시지 전송 + e.preventDefault(); // 기본 Enter 동작(줄바꿈)을 막음 + handleSendText(); + handleSendImage(); } }; return ( - - + {/* 텍스트 전송 */} + {!image && ( + <> + + + + + )} + {/* 이미지 전송 */} + {image && ( + <> + + + + + + + + + )} ); }; @@ -70,6 +192,7 @@ const StyledChatInput = styled.div<{ height: string }>` width: 100%; min-height: 5.4rem; display: flex; + justify-content: space-between; align-items: center; padding: 6px 16px; gap: 8px; @@ -84,3 +207,24 @@ const StyledTextarea = styled.textarea` overflow-y: hidden; font-size: var(--font-size-sm); `; + +const PreviewImageContainer = styled.div` + position: relative; + max-height: 120px; + aspect-ratio: 1 / 1; +`; + +const PreviewImage = styled.img` + width: 100%; + object-fit: cover; + border-radius: var(--border-radius); + border: var(--color-gray-md) 1px solid; +`; + +const DeleteImageButton = styled.button` + position: absolute; + top: 4px; + right: 4px; + outline: none; + color: var(--color-gray-md); +`; diff --git a/src/pages/chats/ChatRoom/components/Date/index.tsx b/src/pages/chats/ChatRoom/components/Date/index.tsx deleted file mode 100644 index 26e02efe..00000000 --- a/src/pages/chats/ChatRoom/components/Date/index.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import styled from '@emotion/styled'; - -const Date = ({ date }: { date: string }) => {date}; - -export default Date; - -const StyledDate = styled.p` - padding: 16px; - color: var(--color-gray-dk); - text-align: center; - font-size: var(--font-size-xs); - font-weight: 500; -`; diff --git a/src/pages/chats/ChatRoom/components/MessageItem/index.tsx b/src/pages/chats/ChatRoom/components/MessageItem/index.tsx index 5a8e383c..061d15b1 100644 --- a/src/pages/chats/ChatRoom/components/MessageItem/index.tsx +++ b/src/pages/chats/ChatRoom/components/MessageItem/index.tsx @@ -1,30 +1,38 @@ import styled from '@emotion/styled'; import ProfileImage from '@/components/common/ProfileImage'; - -type MessageType = 'send' | 'receive'; // todo: dto에 맞춰 바꾸기 +import { formatTimestamp } from '@/utils'; +import { Box } from '@chakra-ui/react'; export type MessageItemProps = { - type: MessageType; - imageUrl?: string; - time: string; - message: string; + senderName: string; + profileImageUrl?: string; + timestamp: string; + content: string; }; -const MessageItem = ({ type, imageUrl, time, message }: MessageItemProps) => { +const MessageItem = ({ senderName, profileImageUrl, timestamp, content }: MessageItemProps) => { + const isMe = true; // todo: 고치기 + const timeAsString = formatTimestamp(timestamp); // 시간만 추출 + return ( - - {type === 'send' && ( + + {isMe && ( <> - - {message} + + {content} )} - {type === 'receive' && ( + {!isMe && ( <> - - {message} - + + + {senderName} + + {content} + + + )} @@ -33,7 +41,7 @@ const MessageItem = ({ type, imageUrl, time, message }: MessageItemProps) => { export default MessageItem; -const StyledMessageItem = styled.div<{ type: MessageType }>` +const StyledMessageItem = styled.div<{ isMe: boolean }>` width: 100%; height: auto; padding: 0 16px 8px; @@ -41,19 +49,25 @@ const StyledMessageItem = styled.div<{ type: MessageType }>` gap: 8px; align-items: flex-start; - ${({ type }) => - type === 'send' && + ${({ isMe }) => + isMe === true && ` justify-content: flex-end; `} - ${({ type }) => - type === 'receive' && + ${({ isMe }) => + isMe === false && ` justify-content: flex-start; `} `; +const Name = styled.p` + color: var(--color-black); + font-size: var(--font-size-sm); + white-space: nowrap; +`; + const Time = styled.span` color: var(--color-gray-dk); font-size: var(--font-size-xxs); @@ -61,7 +75,7 @@ const Time = styled.span` align-self: flex-end; `; -const Bubble = styled.div<{ type: MessageType }>` +const Bubble = styled.div<{ isMe: boolean }>` padding: 6px 8px; max-width: 100%; flex-wrap: wrap; @@ -70,16 +84,16 @@ const Bubble = styled.div<{ type: MessageType }>` border-radius: var(--border-radius); white-space: pre-wrap; - ${({ type }) => - type === 'send' && + ${({ isMe }) => + isMe === true && ` background-color: var(--color-black); border: none; color: var(--color-white); `} - ${({ type }) => - type === 'receive' && + ${({ isMe }) => + isMe === false && ` background-color: var(--color-white); border: 1px solid var(--color-black); diff --git a/src/pages/chats/ChatRoom/components/MessageList/index.tsx b/src/pages/chats/ChatRoom/components/MessageList/index.tsx new file mode 100644 index 00000000..502c185d --- /dev/null +++ b/src/pages/chats/ChatRoom/components/MessageList/index.tsx @@ -0,0 +1,77 @@ +import styled from '@emotion/styled'; +import { isSameDay } from 'date-fns'; +import { Fragment, useRef } from 'react'; + +import type { ChatMessage } from '@/types/chats'; +import { formatDate, getDay } from '@/utils'; +import MessageItem from '../MessageItem'; + +type MessageListProps = { + messageList: ChatMessage[]; +}; + +const MessageList = ({ messageList }: MessageListProps) => { + const messageListRef = useRef(null); // 메시지 리스트 참조 + + let lastMessageDate: Date | null = null; + + // base64 형식을 이미지 src로 변환 + const displayImage = (encodedContent: string) => { + return `data:image/jpeg;base64,${encodedContent}`; + }; + + return ( + + {messageList.map((item, index) => { + const messageDate = new Date(item.timestamp); // 메시지 타임스탬프 + const formattedDate = formatDate(messageDate.toString()); + const day = getDay(messageDate); + + // lastMessageDate가 없거나 마지막 보여준 날짜와 메시지 타임스탬프의 날짜가 같지 않다면 true로 설정 + const showDate = !lastMessageDate || !isSameDay(lastMessageDate, messageDate); + + if (showDate) { + lastMessageDate = messageDate; // 날짜 UI를 보여주었다면 마지막 날짜를 업데이트 + } + + return ( + + {/* 날짜가 바뀌는 경우에만 렌더링 */} + {showDate && ( + + {formattedDate} {day} + + )} + {item.messageType === 'IMAGE' ? ( + + ) : ( + + )} + + ); + })} + + ); +}; + +export default MessageList; + +const Wrapper = styled.div` + width: 100%; + display: flex; + flex-direction: column; +`; + +const StyledDate = styled.p` + padding: 16px; + color: var(--color-gray-dk); + text-align: center; + font-size: var(--font-size-xs); + font-weight: 500; +`; diff --git a/src/pages/chats/ChatRoom/index.tsx b/src/pages/chats/ChatRoom/index.tsx index 3f370499..7a579a25 100644 --- a/src/pages/chats/ChatRoom/index.tsx +++ b/src/pages/chats/ChatRoom/index.tsx @@ -1,69 +1,113 @@ import styled from '@emotion/styled'; +import { CompatClient, Stomp } from '@stomp/stompjs'; import { useEffect, useState } from 'react'; -import { useNavigate } from 'react-router-dom'; +import { useNavigate, useParams } from 'react-router-dom'; +import SockJS from 'sockjs-client'; -import { connectWebSocket, disconnectWebSocket, type ChatMessage } from '@/apis/chats'; +import { disconnectWebSocket } from '@/apis/chats'; +import useGetChatRoom from '@/apis/chats/useGetChatRoom'; import IconButton from '@/components/common/IconButton'; import Header from '@/components/layouts/Header'; import { HEIGHTS } from '@/styles/constants'; +import type { ChatMessage, ChatRoom } from '@/types/chats'; import ChatInput from './components/ChatInput'; -import Date from './components/Date'; -// import MessageItem from './components/MessageItem'; // parameters 안 맞아서 잠시 사용 안 함 // todo: 파라미터 맞추기 +import MessageList from './components/MessageList'; -// 임시 -const NICKNAME = '미니멀앤'; -const chatRoomId = 1; -const userEmail = 'abc@1618.com'; +export const BASE_URL = import.meta.env.VITE_APP_BASE_URL_CHAT; + +// const senderExample = { +// id: 5, +// email: 'ble6859@knu.ac.kr', +// }; const ChatRoom = () => { const navigate = useNavigate(); + + const { chatRoomId } = useParams(); + const chatRoomIdAsNumber = Number(chatRoomId); + const { data } = useGetChatRoom(chatRoomIdAsNumber); // ChatRoom 타입 + const [client, setClient] = useState(null); // stomp client 상태 + const [chatInputHeight, setChatInputHeight] = useState('5.4rem'); - const [messageList, setMessageList] = useState([]); + const [messageList, setMessageList] = useState([]); // 채팅 메시지 목록 const handleChatInputHeight = (newHeight: string) => { setChatInputHeight(newHeight); }; + // WebSocket 연결 및 구독 설정 useEffect(() => { - connectWebSocket( - chatRoomId, - (receivedMessage: ChatMessage) => { - setMessageList((prev) => [...prev, receivedMessage]); - }, - (error) => { - console.error('WebSocket error:', error); - }, - ); - - // 컴포넌트 언마운트 시 WebSocket 연결 해제 - return () => disconnectWebSocket(); - }, [chatRoomId]); + const socket = new SockJS(`${BASE_URL}/ws`); + const stompClient = Stomp.over(() => socket); + + stompClient.connect({}, () => { + // SUBSCRIBE - 채팅방에 대한 초기 메시지 구독 + stompClient.subscribe(`/v1/sub/chat/rooms/${chatRoomIdAsNumber}/list`, (message) => { + const initialMessages = JSON.parse(message.body); + setMessageList(initialMessages); + }); + + // SUBSCRIBE - 새로운 메시지 수신 + stompClient.subscribe(`/v1/sub/chat/rooms/${chatRoomIdAsNumber}`, (message) => { + const receivedMessage = JSON.parse(message.body); + setMessageList((prevMessages) => [...prevMessages, receivedMessage]); + }); + }); + + socket.onclose = (e) => { + console.log('WebSocket closed, attempting to reconnect...', e); + + // 재연결 시도 + setTimeout(() => { + const newSocket = new SockJS(`${BASE_URL}/ws`); + const stompClient = Stomp.over(() => newSocket); + setClient(stompClient); + }, 3000); + }; + + // 클라이언트 상태에 설정 + setClient(stompClient); + + // 컴포넌트가 언마운트될 때 WebSocket 연결 해제 + return () => { + if (stompClient) { + disconnectWebSocket(stompClient); + // stompClient.disconnect(); + } + }; + }, [chatRoomIdAsNumber]); + + // useEffect(() => { + // connectWebSocket( + // chatRoomIdAsNumber, + // (receivedMessage: ChatMessage) => { + // setMessageList((prev) => [...prev, receivedMessage]); + // }, + // (error) => { + // console.error('WebSocket error:', error); + // }, + // ); + + // // 컴포넌트 언마운트 시 WebSocket 연결 해제 + // return () => disconnectWebSocket(); + // }, [chatRoomId]); + + // return (
navigate(-1)} />} - title={NICKNAME} + title={data.title} rightSideChildren={} // todo: onClick -> 모달 /> - - - {messageList && <>messageList} - {/* {messageList.map((item, index) => ( - - ))} */} - + @@ -87,9 +131,3 @@ const ContentWrapper = styled.div<{ marginBottom: string }>` display: flex; flex-direction: column; `; - -const MessageGroupByDate = styled.div` - width: 100%; - display: flex; - flex-direction: column; -`; diff --git a/src/routes/index.tsx b/src/routes/index.tsx index b9f28aa7..b80bdee6 100644 --- a/src/routes/index.tsx +++ b/src/routes/index.tsx @@ -105,7 +105,7 @@ const router = createBrowserRouter([ { path: RouterPath.chats, element: , - children: [{ path: ':id', element: }], + children: [{ path: ':chatRoomId', element: }], }, { path: RouterPath.login, diff --git a/src/types/chats.ts b/src/types/chats.ts new file mode 100644 index 00000000..0abbde6f --- /dev/null +++ b/src/types/chats.ts @@ -0,0 +1,23 @@ +export type User = { + id: number; + email: string; +}; + +export type ChatRoom = { + id: number; + user1: User; + user2: User; + title: string; +}; + +export type MessageType = 'ENTER' | 'TALK' | 'EXIT' | 'IMAGE' | 'TEXT'; + +export type ChatMessage = { + id: number; + chatRoom: ChatRoom; + sender: User; + content: string; + imageUrl?: string; + timestamp: string; // $date-time // YYYY-MM-DDTHH:mm:ss.sssZ + messageType: MessageType; +}; diff --git a/src/utils/index.ts b/src/utils/index.ts index 7d12a8fa..22eebd7d 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -1,11 +1,53 @@ -// 날짜 포맷 함수 +/** + * 날짜 관련 함수 + */ + +// ISO 날짜 형식을 2024.00.00 형식으로 반환하는 함수 export function formatDate(dateStr: string): string { const dateObj = new Date(dateStr); return `${dateObj.getFullYear()}.${String(dateObj.getMonth() + 1).padStart(2, '0')}.${String(dateObj.getDate()).padStart(2, '0')}`; - // 2024.00.00 형식으로 반환 } +// ISO 날짜 형식을 12:30 PM 형식으로 반환하는 함수 +export function formatTimestamp(timestamp: string): string { + const date = new Date(timestamp); + + // KST(UTC+9)으로 변경 + date.setUTCHours(date.getUTCHours() + 9); + + let hours = date.getUTCHours(); + const minutes = date.getUTCMinutes().toString().padStart(2, '0'); + let period = 'A.M.'; + + // 12시인 경우는 P.M.으로 표시 + if (hours >= 12) { + period = 'P.M.'; + if (hours > 12) { + hours -= 12; // 오후 1시 이후는 12시간 형식으로 변환 + } + } + + // 0시는 12시로 표시 + if (hours === 0) { + hours = 12; + } + + return `${hours}:${minutes} ${period}`; +} + +// 요일 반환 +export function getDay(date: Date): string { + const dayList = ['일', '월', '화', '수', '목', '금', '토']; + const day = dayList[date.getDay()]; // getDay의 반환값을 인덱스로 해서 요일 찾기 + + return day; +} + +/** + * 문자열 관련 함수 + */ + // 공백 제거 함수 export function eliminateSpaces(str: string): string { return str.replace(/\s/g, ''); diff --git a/src/utils/queryParams.ts b/src/utils/queryParams.ts new file mode 100644 index 00000000..69ebf082 --- /dev/null +++ b/src/utils/queryParams.ts @@ -0,0 +1,11 @@ +export function getQueryParams(params: Record) { + const queryParams = new URLSearchParams(); + + Object.entries(params).forEach(([key, value]) => { + queryParams.append(key, String(value)); // value가 any 타입이므로 stringfy하여 추가 + }); + queryParams.toString; + + // 'param1=param1¶m2=param2' 형식으로 반환 + return queryParams; +} From f15e8431665cc6778de3a5909a15d3d54f896517 Mon Sep 17 00:00:00 2001 From: joojjang Date: Fri, 15 Nov 2024 01:26:29 +0900 Subject: [PATCH 3/9] =?UTF-8?q?fix(login):=20=EB=A1=9C=EA=B7=B8=EC=9D=B8?= =?UTF-8?q?=20=EB=A6=AC=EB=8B=A4=EC=9D=B4=EB=A0=89=ED=8A=B8=20=EC=A3=BC?= =?UTF-8?q?=EC=86=8C=20=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/Login/index.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/pages/Login/index.tsx b/src/pages/Login/index.tsx index cf8a8097..aae82309 100644 --- a/src/pages/Login/index.tsx +++ b/src/pages/Login/index.tsx @@ -2,7 +2,7 @@ import { Text } from '@chakra-ui/react'; import styled from '@emotion/styled'; import { useNavigate } from 'react-router-dom'; -import { getKakaoLgoin } from '@/apis/login/useGetKakaoLogin'; +// import { getKakaoLgoin } from '@/apis/login/useGetKakaoLogin'; import KakaoSymbol from '@/assets/kakao-symbol.svg?react'; import Logo from '@/assets/logo.svg?react'; import IconButton from '@/components/common/IconButton'; @@ -20,7 +20,7 @@ const Login = () => { const backgroundImageCreator = BACKGROUND_IMAGE_LIST[randomIndex].creator; const handleLogin = () => { - getKakaoLgoin(); + window.location.href = `http://golden-ratio.duckdns.org/oauth2/login/kakao`; // 외부 경로로 리다이렉트 }; return ( From 49425bc63b4dd8a214adcb58f68efe8d4977b11f Mon Sep 17 00:00:00 2001 From: joojjang Date: Fri, 15 Nov 2024 01:36:54 +0900 Subject: [PATCH 4/9] =?UTF-8?q?refactor(login):=20=EB=A1=9C=EA=B7=B8?= =?UTF-8?q?=EC=9D=B8=20=EB=B2=84=ED=8A=BC=20=EC=BB=B4=ED=8F=AC=EB=84=8C?= =?UTF-8?q?=ED=8A=B8=20=EB=AA=A8=EB=93=88=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/KakaoLoginButton/index.tsx | 31 +++++++++++++++++++ src/pages/Login/index.tsx | 28 +---------------- 2 files changed, 32 insertions(+), 27 deletions(-) create mode 100644 src/pages/Login/components/KakaoLoginButton/index.tsx diff --git a/src/pages/Login/components/KakaoLoginButton/index.tsx b/src/pages/Login/components/KakaoLoginButton/index.tsx new file mode 100644 index 00000000..3552bfcf --- /dev/null +++ b/src/pages/Login/components/KakaoLoginButton/index.tsx @@ -0,0 +1,31 @@ +import styled from '@emotion/styled'; + +import KakaoSymbol from '@/assets/kakao-symbol.svg?react'; + +type KakaoLoginButtonProps = { + onClick: () => void; +}; + +const KakaoLoginButton = ({ onClick }: KakaoLoginButtonProps) => { + return ( + + + 카카오로 시작하기 + + ); +}; + +export default KakaoLoginButton; + +const StyledKakaoLoginButton = styled.button` + display: inline-flex; + justify-content: center; + align-items: center; + margin: 0 16px; + padding: 10px 50px; + background-color: var(--color-yellow-kakao); + border-radius: var(--border-radius); + gap: 16px; + font-size: var(--font-size-md); + font-weight: 500; +`; diff --git a/src/pages/Login/index.tsx b/src/pages/Login/index.tsx index aae82309..1073e81e 100644 --- a/src/pages/Login/index.tsx +++ b/src/pages/Login/index.tsx @@ -3,13 +3,13 @@ import styled from '@emotion/styled'; import { useNavigate } from 'react-router-dom'; // import { getKakaoLgoin } from '@/apis/login/useGetKakaoLogin'; -import KakaoSymbol from '@/assets/kakao-symbol.svg?react'; import Logo from '@/assets/logo.svg?react'; import IconButton from '@/components/common/IconButton'; import Header from '@/components/layouts/Header'; import { BACKGROUND_IMAGE_LIST } from '@/constants/login'; import { RouterPath } from '@/routes/path'; import { HEIGHTS } from '@/styles/constants'; +import KakaoLoginButton from './components/KakaoLoginButton'; const Login = () => { const navigate = useNavigate(); @@ -61,19 +61,6 @@ const Login = () => { export default Login; -type KakaoLoginButtonProps = { - onClick: () => void; -}; - -const KakaoLoginButton = ({ onClick }: KakaoLoginButtonProps) => { - return ( - - - 카카오로 시작하기 - - ); -}; - const Wrapper = styled.div<{ backgroundImage: string }>` display: flex; flex-direction: column; @@ -108,16 +95,3 @@ const ContentWrapper = styled.div` } } `; - -const StyledKakaoLoginButton = styled.button` - display: inline-flex; - justify-content: center; - align-items: center; - margin: 0 16px; - padding: 10px 50px; - background-color: var(--color-yellow-kakao); - border-radius: var(--border-radius); - gap: 16px; - font-size: var(--font-size-md); - font-weight: 500; -`; From f10c6f570a84e84c1f894d06f9883b5e6702a13d Mon Sep 17 00:00:00 2001 From: joojjang Date: Fri, 15 Nov 2024 01:42:16 +0900 Subject: [PATCH 5/9] =?UTF-8?q?remove(login):=20=EC=B9=B4=EC=B9=B4?= =?UTF-8?q?=EC=98=A4=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20get=20=EC=9A=94?= =?UTF-8?q?=EC=B2=AD=20=ED=95=A8=EC=88=98=20=ED=8C=8C=EC=9D=BC=20=EC=82=AD?= =?UTF-8?q?=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 주소로 리다이렉트 시키면 되고, axios 요청은 할 필요 없음 --- src/apis/login/useGetKakaoLogin.ts | 39 ------------------------------ 1 file changed, 39 deletions(-) delete mode 100644 src/apis/login/useGetKakaoLogin.ts diff --git a/src/apis/login/useGetKakaoLogin.ts b/src/apis/login/useGetKakaoLogin.ts deleted file mode 100644 index 085c8bc2..00000000 --- a/src/apis/login/useGetKakaoLogin.ts +++ /dev/null @@ -1,39 +0,0 @@ -// import { useSuspenseQuery } from '@tanstack/react-query'; -import { isAxiosError } from 'axios'; - -import fetchInstance from '../fetchInstance'; -// import QUERY_KEYS from '../queryKeys'; - -const BASE_URL = import.meta.env.VITE_APP_BASE_URL; - -type GetLoginResponse = { - accessToken: string; - refreshToken: string; -}; - -export async function getKakaoLgoin(): Promise { - try { - const response = await fetchInstance(BASE_URL).get(`/oauth2/login/kakao`); - - return response.data; - } catch (error) { - if (isAxiosError(error)) { - if (error.response) { - throw new Error(error.response.data.message || '로그인 페이지 이동 실패'); - } else { - throw new Error('네트워크 오류 또는 서버에 연결할 수 없습니다.'); - } - } else { - throw new Error('알 수 없는 오류입니다.'); - } - } -} - -// const useGetKakaoLogin = () => { -// return useSuspenseQuery({ -// queryKey: [QUERY_KEYS.LOGIN], -// queryFn: getKakaoLgoin, -// }); -// }; - -// export default useGetKakaoLogin; From 38a454ad6bd280867ce80c6258d9979292e9c6fe Mon Sep 17 00:00:00 2001 From: joojjang Date: Fri, 15 Nov 2024 01:57:01 +0900 Subject: [PATCH 6/9] =?UTF-8?q?feat(login):=20=EB=A1=9C=EA=B7=B8=EC=9D=B8?= =?UTF-8?q?=20=ED=9B=84=20url=EC=97=90=20=EB=8B=B4=EA=B8=B4=20=ED=86=A0?= =?UTF-8?q?=ED=81=B0=EC=9D=84=20=EB=A1=9C=EC=BB=AC=20=EC=8A=A4=ED=86=A0?= =?UTF-8?q?=EB=A6=AC=EC=A7=80=EC=97=90=20=EC=A0=80=EC=9E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/Home/index.tsx | 8 ++++++-- src/pages/Signup/index.tsx | 7 ++++++- src/utils/setTokens.ts | 11 +++++++++++ 3 files changed, 23 insertions(+), 3 deletions(-) create mode 100644 src/utils/setTokens.ts diff --git a/src/pages/Home/index.tsx b/src/pages/Home/index.tsx index 8bff3947..1cb51784 100644 --- a/src/pages/Home/index.tsx +++ b/src/pages/Home/index.tsx @@ -1,17 +1,22 @@ import styled from '@emotion/styled'; -import { useState } from 'react'; +import { useEffect, useState } from 'react'; import SearchModal from '@/components/common/SearchModal'; import Footer from '@/components/layouts/Footer'; import Header from '@/components/layouts/Header'; import { AD_LIST, ARTICLE_LIST } from '@/constants/home'; import { HEIGHTS } from '@/styles/constants'; +import { setTokens } from '@/utils/setTokens'; import AdBanner from './components/AdBanner'; import ArticleBanner from './components/ArticleBanner'; const Home = () => { const [isModalOpen, setIsModalOpen] = useState(false); + useEffect(() => { + setTokens(); + }, []); + const handleModalOpen = () => { setIsModalOpen(true); }; @@ -19,7 +24,6 @@ const Home = () => { return ( {isModalOpen && setIsModalOpen(false)} />} -
{ARTICLE_LIST.map((item) => ( diff --git a/src/pages/Signup/index.tsx b/src/pages/Signup/index.tsx index a9250fbd..80521208 100644 --- a/src/pages/Signup/index.tsx +++ b/src/pages/Signup/index.tsx @@ -1,5 +1,5 @@ import styled from '@emotion/styled'; -import { useState } from 'react'; +import { useEffect, useState } from 'react'; import { useNavigate } from 'react-router-dom'; import IconButton from '@/components/common/IconButton'; @@ -9,6 +9,7 @@ import useStudentArtistStore from '@/store/useStudentArtistStore'; import useUserStore from '@/store/useUserStore'; import { HEIGHTS } from '@/styles/constants'; import type { Mode } from '@/types'; +import { setTokens } from '@/utils/setTokens'; import SellerProgress from './progresses/ArtistProgress'; import DefaultProgress from './progresses/DefaultProgress'; import UserProgress from './progresses/UserProgress'; @@ -20,6 +21,10 @@ const Signup = () => { const [memberType, setMemberType] = useState(); const [progressStep, setProgressStep] = useState<'default' | Mode>('default'); + useEffect(() => { + setTokens(); + }, []); + const handleMemberTypeSelection = (type: Mode) => { setMemberType(type); setProgressStep(type === 'user' ? 'user' : 'artist'); diff --git a/src/utils/setTokens.ts b/src/utils/setTokens.ts new file mode 100644 index 00000000..700c5d59 --- /dev/null +++ b/src/utils/setTokens.ts @@ -0,0 +1,11 @@ +// 로그인 후 호출되어 query param의 토큰을 로컬 스토리지에 저장 +export function setTokens(): void { + const queryParams = new URLSearchParams(window.location.search); + const accessToken = queryParams.get('accessToken'); + const refreshToken = queryParams.get('refreshToken'); + + if (accessToken && refreshToken) { + localStorage.setItem('accessToken', accessToken); + localStorage.setItem('refreshToken', refreshToken); + } +} From 83116b2c2944127e886a9e4cad8b39f410343f65 Mon Sep 17 00:00:00 2001 From: joojjang Date: Fri, 15 Nov 2024 01:59:14 +0900 Subject: [PATCH 7/9] =?UTF-8?q?chore(queryParams):=20=EC=BF=BC=EB=A6=AC=20?= =?UTF-8?q?=ED=8C=8C=EB=9D=BC=EB=AF=B8=ED=84=B0=20=EA=B4=80=EB=A0=A8=20?= =?UTF-8?q?=ED=95=A8=EC=88=98=EB=93=A4=20=EB=AA=A8=EC=9D=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/Home/index.tsx | 2 +- src/pages/Signup/index.tsx | 2 +- src/utils/queryParams.ts | 13 +++++++++++++ src/utils/setTokens.ts | 11 ----------- 4 files changed, 15 insertions(+), 13 deletions(-) delete mode 100644 src/utils/setTokens.ts diff --git a/src/pages/Home/index.tsx b/src/pages/Home/index.tsx index 1cb51784..96b4b25d 100644 --- a/src/pages/Home/index.tsx +++ b/src/pages/Home/index.tsx @@ -6,7 +6,7 @@ import Footer from '@/components/layouts/Footer'; import Header from '@/components/layouts/Header'; import { AD_LIST, ARTICLE_LIST } from '@/constants/home'; import { HEIGHTS } from '@/styles/constants'; -import { setTokens } from '@/utils/setTokens'; +import { setTokens } from '@/utils/queryParams'; import AdBanner from './components/AdBanner'; import ArticleBanner from './components/ArticleBanner'; diff --git a/src/pages/Signup/index.tsx b/src/pages/Signup/index.tsx index 80521208..249b4526 100644 --- a/src/pages/Signup/index.tsx +++ b/src/pages/Signup/index.tsx @@ -9,7 +9,7 @@ import useStudentArtistStore from '@/store/useStudentArtistStore'; import useUserStore from '@/store/useUserStore'; import { HEIGHTS } from '@/styles/constants'; import type { Mode } from '@/types'; -import { setTokens } from '@/utils/setTokens'; +import { setTokens } from '@/utils/queryParams'; import SellerProgress from './progresses/ArtistProgress'; import DefaultProgress from './progresses/DefaultProgress'; import UserProgress from './progresses/UserProgress'; diff --git a/src/utils/queryParams.ts b/src/utils/queryParams.ts index 69ebf082..50fe8447 100644 --- a/src/utils/queryParams.ts +++ b/src/utils/queryParams.ts @@ -1,3 +1,4 @@ +// query param으로 만들어 반환 export function getQueryParams(params: Record) { const queryParams = new URLSearchParams(); @@ -9,3 +10,15 @@ export function getQueryParams(params: Record) { // 'param1=param1¶m2=param2' 형식으로 반환 return queryParams; } + +// 로그인 후 호출되어 query param의 토큰을 로컬 스토리지에 저장 +export function setTokens(): void { + const queryParams = new URLSearchParams(window.location.search); + const accessToken = queryParams.get('accessToken'); + const refreshToken = queryParams.get('refreshToken'); + + if (accessToken && refreshToken) { + localStorage.setItem('accessToken', accessToken); + localStorage.setItem('refreshToken', refreshToken); + } +} diff --git a/src/utils/setTokens.ts b/src/utils/setTokens.ts deleted file mode 100644 index 700c5d59..00000000 --- a/src/utils/setTokens.ts +++ /dev/null @@ -1,11 +0,0 @@ -// 로그인 후 호출되어 query param의 토큰을 로컬 스토리지에 저장 -export function setTokens(): void { - const queryParams = new URLSearchParams(window.location.search); - const accessToken = queryParams.get('accessToken'); - const refreshToken = queryParams.get('refreshToken'); - - if (accessToken && refreshToken) { - localStorage.setItem('accessToken', accessToken); - localStorage.setItem('refreshToken', refreshToken); - } -} From 2c0e3244589f1415260a71247599ece408ec5121 Mon Sep 17 00:00:00 2001 From: joojjang Date: Fri, 15 Nov 2024 02:01:16 +0900 Subject: [PATCH 8/9] =?UTF-8?q?refactor(getQueryParams):=20=EB=B0=98?= =?UTF-8?q?=ED=99=98=20=ED=83=80=EC=9E=85=20=EC=A7=80=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/utils/queryParams.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/utils/queryParams.ts b/src/utils/queryParams.ts index 50fe8447..fd7f8fd0 100644 --- a/src/utils/queryParams.ts +++ b/src/utils/queryParams.ts @@ -1,5 +1,5 @@ // query param으로 만들어 반환 -export function getQueryParams(params: Record) { +export function getQueryParams(params: Record): URLSearchParams { const queryParams = new URLSearchParams(); Object.entries(params).forEach(([key, value]) => { From 79033310f5dda151ad95f10f2ae88500b53d9b22 Mon Sep 17 00:00:00 2001 From: joojjang Date: Fri, 15 Nov 2024 02:19:12 +0900 Subject: [PATCH 9/9] =?UTF-8?q?feat(getUserMode):=20=EC=9C=A0=EC=A0=80=20?= =?UTF-8?q?=EB=AA=A8=EB=93=9C=20get=20=ED=95=A8=EC=88=98=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 응답 확인하고 이후 로직 구현 - 클라이언트에 유저 모드 세팅해야 함 --- src/apis/users/getUserMode.ts | 38 +++++++++++++++++++++++++++++++++++ src/pages/Home/index.tsx | 12 ++++++++++- 2 files changed, 49 insertions(+), 1 deletion(-) create mode 100644 src/apis/users/getUserMode.ts diff --git a/src/apis/users/getUserMode.ts b/src/apis/users/getUserMode.ts new file mode 100644 index 00000000..70a51eb2 --- /dev/null +++ b/src/apis/users/getUserMode.ts @@ -0,0 +1,38 @@ +// import { useQuery } from '@tanstack/react-query'; +import { isAxiosError } from 'axios'; + +import fetchInstance from '../fetchInstance'; +// import QUERY_KEYS from '../queryKeys'; + +type UserModeResponse = { role: string; userType: string }; + +async function getUserMode(): Promise { + try { + const response = await fetchInstance().get('/users/type'); + + return response.data.data; // todo: dto 확인하기, 타입이 뭔지 확인 + } catch (error) { + if (isAxiosError(error)) { + if (error.response) { + throw new Error(error.response.data.message || 'user mode 가져오기 실패'); + } else { + throw new Error('네트워크 오류 또는 서버에 연결할 수 없습니다.'); + } + } else { + throw new Error('알 수 없는 오류입니다.'); + } + } +} + +// const useGetFollow = () => { +// const { data, status, refetch } = useQuery, Error>({ +// queryKey: [QUERY_KEYS.FOLLOW_LIST], +// queryFn: getFollow, +// }); + +// return { data, status, refetch }; +// }; + +// export default useGetFollow; + +export default getUserMode; diff --git a/src/pages/Home/index.tsx b/src/pages/Home/index.tsx index 96b4b25d..b1d4a887 100644 --- a/src/pages/Home/index.tsx +++ b/src/pages/Home/index.tsx @@ -1,6 +1,7 @@ import styled from '@emotion/styled'; import { useEffect, useState } from 'react'; +import getUserMode from '@/apis/users/getUserMode'; import SearchModal from '@/components/common/SearchModal'; import Footer from '@/components/layouts/Footer'; import Header from '@/components/layouts/Header'; @@ -14,7 +15,16 @@ const Home = () => { const [isModalOpen, setIsModalOpen] = useState(false); useEffect(() => { - setTokens(); + const initializeTokensAndUserMode = async () => { + await setTokens(); + + const accessToken = localStorage.getItem('accessToken'); + if (accessToken) { + getUserMode(); + } + }; + + initializeTokensAndUserMode(); }, []); const handleModalOpen = () => {