diff --git a/frontend/.yarn/cache/react-webcam-npm-7.2.0-4404f3658d-d639a9e4cd.zip b/frontend/.yarn/cache/react-webcam-npm-7.2.0-4404f3658d-d639a9e4cd.zip new file mode 100644 index 0000000..a8a2b73 Binary files /dev/null and b/frontend/.yarn/cache/react-webcam-npm-7.2.0-4404f3658d-d639a9e4cd.zip differ diff --git a/frontend/src/apis/firebase/auth.ts b/frontend/src/apis/firebase/auth.ts index 0e24c4c..e583fb1 100644 --- a/frontend/src/apis/firebase/auth.ts +++ b/frontend/src/apis/firebase/auth.ts @@ -32,6 +32,8 @@ const getUserData = async () => { return null; } + const userData = docSnap.data() as TUser; + const likeShoesRef = collection(db, "users", uid, "likeShoes"); const likeShoesSnap = await getDocs(likeShoesRef); const likeShoes = likeShoesSnap.docs.map((doc) => ({ @@ -48,7 +50,7 @@ const getUserData = async () => { return { uid, - ...docSnap.data(), + ...userData, likeShoes, shoeCloset, }; diff --git a/frontend/src/apis/firebase/chatFirestore.ts b/frontend/src/apis/firebase/chatFirestore.ts index 1065b9b..80003da 100644 --- a/frontend/src/apis/firebase/chatFirestore.ts +++ b/frontend/src/apis/firebase/chatFirestore.ts @@ -14,6 +14,9 @@ import { setDoc, FieldValue, writeBatch, + startAfter, + QueryDocumentSnapshot, + DocumentData, } from "firebase/firestore"; import { TChatResponse } from "@/types/chat"; @@ -26,6 +29,7 @@ export const getMessagesFromLatestRoom = async ( content: string | TChatResponse; id?: string; }[]; + lastVisibleDoc: QueryDocumentSnapshot | null; }> => { const roomsCollection = collection(db, "chatSessions", userId, "rooms"); // 이 코드는 userId라는 도큐먼트의 하위 컬렉션 rooms에 접근하여, @@ -48,7 +52,7 @@ export const getMessagesFromLatestRoom = async ( }); latestRoomId = newRoomRef.id; - return { roomId: latestRoomId, messages: [] }; + return { roomId: latestRoomId, messages: [], lastVisibleDoc: null }; } else { const latestRoom = roomsSnapshot.docs[0]; latestRoomId = latestRoom.id; @@ -62,12 +66,19 @@ export const getMessagesFromLatestRoom = async ( latestRoomId, "messages" ); - const messagesQuery = query(messagesCollection, orderBy("timestamp", "asc")); + const messagesQuery = query( + messagesCollection, + orderBy("timestamp", "desc"), + limit(5) + ); const messagesSnapshot = await getDocs(messagesQuery); - const messages = messagesSnapshot.docs.flatMap((doc) => { + const messages = messagesSnapshot.docs.reverse().flatMap((doc) => { const data = doc.data(); const result = []; + // 질문과 답변을 한쌍으로 저장했기에, 하나의 doc에 대해서 + // bot과 user 메세지 객체를 분리하여 저장한다. + // 이후에 type이 bot인지 user인지에 따라 화면에 그려지는 위치가 정해진다. // user 메시지가 빈 문자열이 아닌 경우에만 추가 if (data.user && data.user.trim() !== "") { @@ -89,7 +100,10 @@ export const getMessagesFromLatestRoom = async ( return result; }); - return { roomId: latestRoomId, messages }; + const lastVisibleDoc = + messagesSnapshot.docs[messagesSnapshot.docs.length - 1] || null; + + return { roomId: latestRoomId, messages, lastVisibleDoc }; }; export const addMessageToFirestore = async ( @@ -133,8 +147,7 @@ export const addMessageToFirestore = async ( // 성공적으로 메시지가 저장되었으므로 doc.id 반환 return docRef.id; } catch (error) { - console.error("메시지 추가 중 에러 발생: ", error); - return null; // 에러 발생 시 null 반환 + throw new Error(); } }; @@ -257,6 +270,7 @@ export const getMessagesByUserIdAndRoomId = async ( content: string | TChatResponse; id?: string; }[]; + lastVisibleDoc: QueryDocumentSnapshot | null; }> => { try { const messagesCollection = collection( @@ -269,11 +283,12 @@ export const getMessagesByUserIdAndRoomId = async ( ); const messagesQuery = query( messagesCollection, - orderBy("timestamp", "asc") + orderBy("timestamp", "desc"), + limit(5) ); const messagesSnapshot = await getDocs(messagesQuery); - const messages = messagesSnapshot.docs.flatMap((doc) => { + const messages = messagesSnapshot.docs.reverse().flatMap((doc) => { const data = doc.data(); const result = []; @@ -295,10 +310,13 @@ export const getMessagesByUserIdAndRoomId = async ( return result; }); - return { roomId, messages }; + const lastVisibleDoc = + messagesSnapshot.docs[messagesSnapshot.docs.length - 1] || null; + + return { roomId, messages, lastVisibleDoc }; } catch (error) { console.error(error); - return { roomId, messages: [] }; + return { roomId, messages: [], lastVisibleDoc: null }; } }; @@ -330,3 +348,55 @@ export const deleteChatRoom = async (userId: string, roomId: string) => { console.error(error); } }; + +// 스크롤 위로 올렸을 때 호출할 함수 +export const getOlderMessages = async ( + userId: string, + roomId: string, + lastVisibleDoc: QueryDocumentSnapshot | null +) => { + const messagesCollection = collection( + db, + "chatSessions", + userId, + "rooms", + roomId, + "messages" + ); + + const messagesQuery = query( + messagesCollection, + orderBy("timestamp", "desc"), + startAfter(lastVisibleDoc), // 마지막 스냅샷 이후부터 가져옴 + limit(5) + ); + + const messagesSnapshot = await getDocs(messagesQuery); + + const messages = messagesSnapshot.docs.reverse().flatMap((doc) => { + const data = doc.data(); + const result = []; + + if (data.user && data.user.trim() !== "") { + result.push({ + type: "user" as const, + content: data.user as string, + }); + } + + if (data.bot) { + result.push({ + type: "bot" as const, + content: data.bot as TChatResponse, + id: doc.id, + }); + } + + return result; + }); + + const newLastVisibleDoc = + messagesSnapshot.docs[messagesSnapshot.docs.length - 1]; + + return { messages, newLastVisibleDoc }; +}; diff --git a/frontend/src/components/Chat/ChatProductItem.tsx b/frontend/src/components/Chat/ChatProductItem.tsx index a5db168..5eaa944 100644 --- a/frontend/src/components/Chat/ChatProductItem.tsx +++ b/frontend/src/components/Chat/ChatProductItem.tsx @@ -2,7 +2,9 @@ import { addOrRemoveShoeFromLikes } from "@apis/firebase/likeFirestore"; import userStore from "@store/auth.store"; import LikeButton from "@common/LikeButton"; import Img from "@common/html/Img"; -import { useParams } from "react-router-dom"; +import { useNavigate, useParams } from "react-router-dom"; +import useChatStore from "@/store/chat.store"; +import { useBottomSheet } from "@/store/bottomSheet.store"; interface ProductItemProps { brand: string; @@ -28,6 +30,14 @@ const ChatProductItem = (props: ProductItemProps) => { const { messageId } = useParams(); const isSharePage = Boolean(messageId); + const { addGuestMessage } = useChatStore(); + const { open } = useBottomSheet(); + const navigate = useNavigate(); + const goToLogin = () => { + navigate("#login"); + open("login"); + }; + const handleLikeClick = async (e: React.MouseEvent) => { e.stopPropagation(); if (isLoggedIn) { @@ -41,8 +51,15 @@ const ChatProductItem = (props: ProductItemProps) => { }); updateUserInfo(); } else { - console.log("로그인이 필요합니다."); - // 여기서 로그인하라는 채팅을 띄워주면 좋을 듯 하다. 일단 나중에 .. + addGuestMessage({ + type: "bot", + content: { + message: "로그인이 필요한 기능입니다.", + }, + }); + setTimeout(() => { + goToLogin(); + }, 1500); } }; return ( diff --git a/frontend/src/components/Chat/ChatShareDislikeBox.tsx b/frontend/src/components/Chat/ChatShareDislikeBox.tsx index 1d2405e..16a0ab2 100644 --- a/frontend/src/components/Chat/ChatShareDislikeBox.tsx +++ b/frontend/src/components/Chat/ChatShareDislikeBox.tsx @@ -10,6 +10,8 @@ import { TChatResponse } from "@/types/chat"; import { Timestamp } from "firebase/firestore"; import ShareModal from "@common/ShareModal"; import useToggle from "@hooks/useToggle"; +import { useNavigate } from "react-router-dom"; +import { useBottomSheet } from "@/store/bottomSheet.store"; interface ChatShareDislikeBoxProps { docId?: string | null; @@ -29,9 +31,18 @@ const ChatShareDislikeBox = (props: ChatShareDislikeBoxProps) => { timestamp: Timestamp; }>(); const { user } = userStore(); - const { roomId } = useChatStore(); + const { roomId, addGuestMessage } = useChatStore(); const userId = user?.uid!; + const { open } = useBottomSheet(); + + const navigate = useNavigate(); + + const goToLogin = () => { + navigate("#login"); + open("login"); + }; + const handleOpenModal = async () => { if (user) { try { @@ -44,7 +55,15 @@ const ChatShareDislikeBox = (props: ChatShareDislikeBoxProps) => { console.error(error); } } else { - console.log("로그인 해야 모달창이 열린단다"); + addGuestMessage({ + type: "bot", + content: { + message: "로그인이 필요한 기능입니다.", + }, + }); + setTimeout(() => { + goToLogin(); + }, 1500); } }; diff --git a/frontend/src/components/login/Login.tsx b/frontend/src/components/login/Login.tsx index c4d0fd5..72cc280 100644 --- a/frontend/src/components/login/Login.tsx +++ b/frontend/src/components/login/Login.tsx @@ -14,7 +14,7 @@ import useFocus from "@hooks/useFocus"; const Login = () => { const navigate = useNavigate(); - const { updateUserInfo, user, isLoggedIn } = userStore(); + const { updateUserInfo, isLoggedIn } = userStore(); const { roomId, addUserMessage } = useChatStore(); const [loginData, setLoginData] = useState({ email: "", @@ -23,7 +23,13 @@ const Login = () => { const [emailError, setEmailError] = useState(""); const [passwordError, setPasswordError] = useState(""); - const { closeAll } = useBottomSheet(); + const { closeAll, open } = useBottomSheet(); + + const goToSignUp = () => { + closeAll(); + navigate("#signup"); + open("login"); + }; // 자동 포커스 const [emailRef, focusEmail, handleEmailKeyPress] = @@ -81,24 +87,25 @@ const Login = () => { //값 확인용 if (isLoginValid) { - await signInWithCredential(loginData).then(() => { - updateUserInfo(); - closeAll(); - navigate("/"); - }); - - // 여기서 왜 isLoggedIn이 false 일까.. - // 루트 경로로 보냈으니 Layout 컴포넌트에서 로그인 상태 변경 해줘야 하는거 아닌가?? - // 일단 급한대로 아래 try문에서 로그인 상태 확인 없이 진행 - console.log(isLoggedIn); + await signInWithCredential(loginData); + // zustand로 관리하는 user가 업데이트가 바로 안이루어져서, + // 임시 방편으로 updateUserInfo 가 userData를 반환하게끔 하고 + // 반환값을 사용하도록 하자 + // 필요한 데이터만 구조분해할당 + const { uid, username } = (await updateUserInfo()) as { + uid: string; + username: string; + }; + closeAll(); + navigate("/"); // 여기서 맞춤상품 api 호출 처리 try { - const loginMent = `반갑습니다 ${user?.username}님! ${user?.username}님을 위한 맞춤 상품을 추천해 드릴께요`; + const loginMent = `반갑습니다 ${username!}님! ${username!}님을 위한 맞춤 상품을 추천해 드릴께요`; const res = await chatApi.getCustomizedProduct(); if (res.status === 200) { - await addMessageToFirestore(user?.uid!, roomId!, "", { + await addMessageToFirestore(uid!, roomId!, "", { message: loginMent, reqProducts: res.data, } as TChatResponse); @@ -152,6 +159,12 @@ const Login = () => { onKeyDown={(e) => handlePasswordPress(e, focusFormButton)} /> + + 아이디가 없으신가요? +
{/*로그인 폼 제출 버튼*/} diff --git a/frontend/src/components/sidemenu/SideMenu.tsx b/frontend/src/components/sidemenu/SideMenu.tsx index 385fbad..f536ef5 100644 --- a/frontend/src/components/sidemenu/SideMenu.tsx +++ b/frontend/src/components/sidemenu/SideMenu.tsx @@ -119,7 +119,10 @@ const SideMenu = ({ await deleteChatRoom(userId, deleteId); const updatedChats = await getUserChatRooms(user?.uid!); setChats(updatedChats); - // 지우려는 방이 현재 속한 방이라면 채팅방의 내용을 가장 최신 채팅으로 업데이트 + + // 지우려는 방이 현재 속한 방이라면 채팅방의 내용을 + // 가장 최신 채팅이 이루어진 채팅방으로 업데이트 + // (채팅방을 삭제했을 때 채팅창에 메세지가 전부 다 사라지면 사용자 경험 측면에서 안좋으니.) if (deleteId === roomId) { loadUserMessages(userId); } diff --git a/frontend/src/components/signup/SignUpRequired.tsx b/frontend/src/components/signup/SignUpRequired.tsx index f4abe5a..5a40306 100644 --- a/frontend/src/components/signup/SignUpRequired.tsx +++ b/frontend/src/components/signup/SignUpRequired.tsx @@ -8,12 +8,15 @@ import { useInput } from "@hooks/useInput"; import userStore from "@store/auth.store"; import { auth } from "@/firebase"; import useFocus from "@hooks/useFocus"; +import { useBottomSheet } from "@/store/bottomSheet.store"; +import { useNavigate } from "react-router-dom"; const SignUpRequired = () => { const user = auth.currentUser; const { updateUserInfo } = userStore((store) => ({ updateUserInfo: store.updateUserInfo, })); + const { closeAll, open } = useBottomSheet(); const { value: signUpRequired, setValue: setSignUpRequired } = useInput({ email: user?.email || "", password: user ? "blocked" : "", @@ -178,6 +181,14 @@ const SignUpRequired = () => { } }; + const navigate = useNavigate(); + + const goToLogin = () => { + closeAll(); + navigate("#login"); + open("login"); + }; + return ( <>
@@ -276,6 +287,12 @@ const SignUpRequired = () => { />
+ + 이미 아이디가 있으신가요? +
{/*회원가입 다음 페이지로 이동 버튼*/} diff --git a/frontend/src/pages/Chat/Chat.tsx b/frontend/src/pages/Chat/Chat.tsx index 330e800..f99aa7b 100644 --- a/frontend/src/pages/Chat/Chat.tsx +++ b/frontend/src/pages/Chat/Chat.tsx @@ -87,10 +87,7 @@ const Chat = () => { }); }, 0); } - }, [ - isLoggedIn ? userMessages.length : guestMessages.length, - isAllSheetsOpen, - ]); + }, [isLoggedIn ? userMessages : guestMessages.length, isAllSheetsOpen]); return (
diff --git a/frontend/src/store/auth.store.ts b/frontend/src/store/auth.store.ts index 506a75a..0dba059 100644 --- a/frontend/src/store/auth.store.ts +++ b/frontend/src/store/auth.store.ts @@ -39,7 +39,7 @@ interface UserState { likeShoes: likeShoes; // 신발장의 신발들 관리하는 상태 shoeCloset: shoeCloset; - updateUserInfo: () => void; + updateUserInfo: () => Promise; // async 함수는 암묵적으로 Promise를 반환한다. setUserInfo: (key: string, value: string | number) => void; setIsLoggedIn: (state: boolean) => void; } @@ -49,8 +49,9 @@ const userStore = create((set) => ({ user: null, likeShoes: null, shoeCloset: null, - updateUserInfo: () => { - getUserData().then((data) => { + updateUserInfo: async () => { + try { + const data = await getUserData(); if (data) { const { likeShoes, shoeCloset, ...userData } = data; set({ @@ -58,10 +59,13 @@ const userStore = create((set) => ({ likeShoes: likeShoes as likeShoes, shoeCloset: shoeCloset as shoeCloset, }); + return userData; } else { set({ user: null }); } - }); + } catch (error) { + console.error(error); + } }, setUserInfo: (key: string, value: string | number) => { set((user) => ({ ...user, [key]: value })); diff --git a/frontend/src/store/chat.store.ts b/frontend/src/store/chat.store.ts index 815b1a8..8c12914 100644 --- a/frontend/src/store/chat.store.ts +++ b/frontend/src/store/chat.store.ts @@ -3,7 +3,9 @@ import { TChatResponse } from "@/types/chat"; import { getMessagesByUserIdAndRoomId, getMessagesFromLatestRoom, + getOlderMessages, } from "@/apis/firebase/chatFirestore"; +import { DocumentData, QueryDocumentSnapshot } from "firebase/firestore"; interface UserMessage { type: "user"; @@ -20,19 +22,22 @@ interface ChatState { guestMessages: (UserMessage | BotMessage)[]; userMessages: (UserMessage | BotMessage)[]; roomId: string | null; + lastVisibleDoc: QueryDocumentSnapshot | null; setRoomId: (roomId: string) => void; addGuestMessage: (message: UserMessage | BotMessage) => void; addUserMessage: (message: UserMessage | BotMessage) => void; loadGuestMessages: () => void; - loadUserMessages: (userId: string) => void; + loadUserMessages: (userId: string) => Promise; getMessagesByRoomId: (userId: string, roomId: string) => void; + loadOlderMessages: (userId: string, roomId: string) => Promise; clearGuestMessages: () => void; } -const useChatStore = create((set) => ({ +const useChatStore = create((set, get) => ({ guestMessages: [], userMessages: [], roomId: null, + lastVisibleDoc: null, // roomId 설정 함수 setRoomId: (roomId: string) => set({ roomId }), @@ -62,7 +67,8 @@ const useChatStore = create((set) => ({ // 회원 메시지 로드 (Firestore에서) loadUserMessages: async (userId: string) => { - const { roomId, messages } = await getMessagesFromLatestRoom(userId); + const { roomId, messages, lastVisibleDoc } = + await getMessagesFromLatestRoom(userId); // 어디서부터 꼬인건지.. 일단 여기서 이렇게 해줘야 에러가 안나긴 한다. 나중에 찾아보자.. const formattedMessages = messages.map((msg) => { @@ -79,14 +85,16 @@ const useChatStore = create((set) => ({ } as UserMessage; } }); - - set({ roomId, userMessages: formattedMessages }); + set({ roomId, userMessages: formattedMessages, lastVisibleDoc }); }, // userId와 roomId를 인자로 받아 그에 해당하는 메세지를 가져오는 함수 getMessagesByRoomId: async (userId: string, roomId: string) => { - const { roomId: fetchedRoomId, messages } = - await getMessagesByUserIdAndRoomId(userId, roomId); + const { + roomId: fetchedRoomId, + messages, + lastVisibleDoc, + } = await getMessagesByUserIdAndRoomId(userId, roomId); // 어디서부터 꼬인건지.. 일단 여기서 이렇게 해줘야 에러가 안나긴 한다. 나중에 찾아보자.. const formattedMessages = messages.map((msg) => { @@ -103,10 +111,41 @@ const useChatStore = create((set) => ({ } as UserMessage; } }); - console.log(formattedMessages); - console.log(fetchedRoomId); - set({ roomId: fetchedRoomId, userMessages: formattedMessages }); + set({ + roomId: fetchedRoomId, + userMessages: formattedMessages, + lastVisibleDoc, + }); + }, + + loadOlderMessages: async (userId: string, roomId: string) => { + const { lastVisibleDoc } = get(); + if (!lastVisibleDoc) return; // 마지막 메시지가 없으면 더 이상 로드하지 않음 + const { messages, newLastVisibleDoc } = await getOlderMessages( + userId, + roomId, + lastVisibleDoc + ); + + const formattedMessages = messages.map((msg) => { + if (msg.type === "bot") { + return { + type: "bot", + content: msg.content, + id: msg.id, + } as BotMessage; + } else { + return { + type: "user", + content: msg.content, + } as UserMessage; + } + }); + set((state) => ({ + userMessages: [...formattedMessages, ...state.userMessages], + lastVisibleDoc: newLastVisibleDoc, + })); }, // 비회원 메시지 비우기 (로그인 시)