diff --git a/TinyBite/api/axios.ts b/TinyBite/api/axios.ts index 3fb85d4..95c596e 100644 --- a/TinyBite/api/axios.ts +++ b/TinyBite/api/axios.ts @@ -6,7 +6,6 @@ import * as SecureStore from "expo-secure-store"; // 인증 필요 x export const publicAxios = axios.create({ baseURL: BASE_URL, - withCredentials: true, }); publicAxios.interceptors.request.use( @@ -29,7 +28,6 @@ publicAxios.interceptors.request.use( // 인증 필요 o export const privateAxios = axios.create({ baseURL: BASE_URL, - withCredentials: true, }); privateAxios.interceptors.request.use( @@ -89,11 +87,10 @@ privateAxios.interceptors.response.use( async (error) => { const originalRequest = error.config; - // 401 에러이고 재시도하지 않은 요청인지 확인 + // 401 | 403 에러이고 재시도하지 않은 요청인지 확인 if ( - error.response?.status === 401 && - !originalRequest._retry && - !originalRequest.url?.includes("/refresh") + (error.response?.status === 401 || error.response?.status === 403) && + !originalRequest._retry ) { if (isRefreshing) { return new Promise((resolve, reject) => { @@ -101,6 +98,10 @@ privateAxios.interceptors.response.use( }) .then((token) => { originalRequest.headers.Authorization = `Bearer ${token}`; + if (originalRequest.url?.includes(ENDPOINT.AUTH.REFRESH)) { + useAuthStore.getState().logout(); + return Promise.reject(error); + } return privateAxios(originalRequest); }) .catch((err) => { @@ -114,6 +115,12 @@ privateAxios.interceptors.response.use( try { const refreshToken = await SecureStore.getItemAsync("refreshToken"); + + if (!refreshToken) { + useAuthStore.getState().logout(); + return Promise.reject(new Error("No refresh token")); + } + const res = await publicAxios.post(ENDPOINT.AUTH.REFRESH, { refreshToken: refreshToken, }); @@ -129,6 +136,10 @@ privateAxios.interceptors.response.use( const newAccessToken = await SecureStore.getItemAsync("accessToken"); processQueue(null, newAccessToken); originalRequest.headers.Authorization = `Bearer ${newAccessToken}`; + if (originalRequest.url?.includes(ENDPOINT.AUTH.REFRESH)) { + useAuthStore.getState().logout(); + return Promise.reject(error); + } return privateAxios(originalRequest); } catch (refreshError) { processQueue(refreshError, null); diff --git a/TinyBite/api/chatApi.ts b/TinyBite/api/chatApi.ts index 4584a70..44e05d7 100644 --- a/TinyBite/api/chatApi.ts +++ b/TinyBite/api/chatApi.ts @@ -2,7 +2,10 @@ import { ApiSuccess } from "@/types/api.types"; import { GetChatMessagesParams, GetChatMessagesResponse, + GroupChatCardSchema, + GroupChatDetailSchema, OneToOneChatCardSchema, + OneToOneChatDetailSchema, } from "@/types/chat.types"; import { privateAxios } from "./axios"; import { ENDPOINT } from "./urls"; @@ -36,3 +39,33 @@ export const getOnetoOneRoomList = async () => { ); return res.data.data; }; + +/** + * 1:1 채팅방 디테일 조회 + */ +export const getOnetoOneRoomDetail = async (chatroomId: number) => { + const res = await privateAxios.get>( + ENDPOINT.CHAT_ROOM.DETAIL.ONE_TO_ONE(chatroomId) + ); + return res.data.data; +}; + +/** + * 그룹 채팅방 목록 조회 + */ +export const getGroupRoomList = async () => { + const res = await privateAxios.get>( + ENDPOINT.CHAT_ROOM.GROUP + ); + return res.data.data; +}; + +/** + * 그룹 채팅방 디테일 조회 + */ +export const getGroupRoomDetail = async (chatroomId: number) => { + const res = await privateAxios.get>( + ENDPOINT.CHAT_ROOM.DETAIL.GROUP(chatroomId) + ); + return res.data.data; +}; diff --git a/TinyBite/api/partyApi.ts b/TinyBite/api/partyApi.ts index 49bb15e..32d0e22 100644 --- a/TinyBite/api/partyApi.ts +++ b/TinyBite/api/partyApi.ts @@ -166,9 +166,17 @@ export const patchParty = async ({ /* 참여중인 파티 리스트 조회 API * @returns 참여중인 파티 리스트 */ -export const getActiveParties = async (): Promise => { +export const getActiveParties = async ( + latitude: number | undefined, + longitude: number | undefined +): Promise => { try { - const res = await privateAxios.get(ENDPOINT.USER.ACTIVE_PARTIES); + const res = await privateAxios.get(ENDPOINT.USER.ACTIVE_PARTIES, { + params: { + latitude, + longitude, + }, + }); return res.data || []; } catch (error) { console.error("참여중인 파티 리스트 로딩 실패:", error); @@ -180,9 +188,17 @@ export const getActiveParties = async (): Promise => { * 호스팅 중인 파티 리스트 조회 API * @returns 호스팅 중인 파티 리스트 */ -export const getHostingParties = async (): Promise => { +export const getHostingParties = async ( + latitude: number | undefined, + longitude: number | undefined +): Promise => { try { - const res = await privateAxios.get(ENDPOINT.USER.HOSTING_PARTIES); + const res = await privateAxios.get(ENDPOINT.USER.HOSTING_PARTIES, { + params: { + latitude, + longitude, + }, + }); return res.data || []; } catch (error) { console.error("호스팅 중인 파티 리스트 로딩 실패:", error); @@ -270,3 +286,41 @@ export const postRequestJoinParty = async (partyId: number) => { return res.data.data; }; + +/** + * 파티 참여 승인 + */ +export const postApproveJoinParty = async ( + partyId: number, + participantId: number +) => { + await privateAxios.post>( + ENDPOINT.PARTY.PARTICIPANTS.APPROVE(partyId, participantId) + ); +}; + +/** + * 파티 참여 거절 + */ +export const postRejectJoinParty = async ( + partyId: number, + participantId: number +) => { + await privateAxios.post>( + ENDPOINT.PARTY.PARTICIPANTS.REJECT(partyId, participantId) + ); +}; + +/** + * 파티 인원 모집 완료 + */ +export const postCompleteParty = async (partyId: number) => { + await privateAxios.patch>(ENDPOINT.PARTY.COMPLETE(partyId)); +}; + +/** + * 파티 종료 + */ +export const postSettleParty = async (partyId: number) => { + await privateAxios.post>(ENDPOINT.PARTY.SETTLE(partyId)); +}; diff --git a/TinyBite/api/urls.ts b/TinyBite/api/urls.ts index bc480b1..499e30b 100644 --- a/TinyBite/api/urls.ts +++ b/TinyBite/api/urls.ts @@ -28,6 +28,14 @@ export const ENDPOINT = { SEARCH_LOG: "/api/parties/search/log", SEARCH_LOG_DELETE: (keyword: string) => `/api/parties/search/log/${keyword}`, + PARTICIPANTS: { + APPROVE: (partyId: number, participantId: number) => + `/api/parties/${partyId}/participants/${participantId}/approve`, + REJECT: (partyId: number, participantId: number) => + `/api/parties/${partyId}/participants/${participantId}/reject`, + }, + COMPLETE: (partyId: number) => `/api/parties/${partyId}/complete`, + SETTLE: (partyId: number) => `/api/parties/${partyId}/settle`, }, FILE: { UPLOAD_FILE: "api/v1/file/upload", @@ -47,8 +55,15 @@ export const ENDPOINT = { WS_SEND: "/publish/send", }, CHAT_ROOM: { + // 채팅방 리스트 조회 ONE_TO_ONE: "/api/v1/chatroom/one-to-one", GROUP: "/api/v1/chatroom/group", + // 채팅방 정보 조회 + DETAIL: { + ONE_TO_ONE: (chatroomId: number) => + `/api/v1/chatroom/one-to-one/${chatroomId}`, + GROUP: (chatroomId: number) => `/api/v1/chatroom/group/${chatroomId}`, + }, }, FCM: { TOKEN: "/api/v1/fcm/token", diff --git a/TinyBite/app.config.js b/TinyBite/app.config.js index a82076e..34234f7 100644 --- a/TinyBite/app.config.js +++ b/TinyBite/app.config.js @@ -4,7 +4,7 @@ module.exports = { name: "한입만", slug: "TinyBite", owner: "tinybite-2025", - version: "0.4.0", + version: "0.9.0", orientation: "portrait", icon: "./assets/images/icon.png", scheme: "tinybite", diff --git a/TinyBite/app/(mypage)/edit.tsx b/TinyBite/app/(app)/(mypage)/edit.tsx similarity index 100% rename from TinyBite/app/(mypage)/edit.tsx rename to TinyBite/app/(app)/(mypage)/edit.tsx diff --git a/TinyBite/app/(mypage)/neighborhood.tsx b/TinyBite/app/(app)/(mypage)/neighborhood.tsx similarity index 100% rename from TinyBite/app/(mypage)/neighborhood.tsx rename to TinyBite/app/(app)/(mypage)/neighborhood.tsx diff --git a/TinyBite/app/(mypage)/notification.tsx b/TinyBite/app/(app)/(mypage)/notification.tsx similarity index 100% rename from TinyBite/app/(mypage)/notification.tsx rename to TinyBite/app/(app)/(mypage)/notification.tsx diff --git a/TinyBite/app/(mypage)/setting.tsx b/TinyBite/app/(app)/(mypage)/setting.tsx similarity index 100% rename from TinyBite/app/(mypage)/setting.tsx rename to TinyBite/app/(app)/(mypage)/setting.tsx diff --git a/TinyBite/app/(tabs)/_layout.tsx b/TinyBite/app/(app)/(tabs)/_layout.tsx similarity index 100% rename from TinyBite/app/(tabs)/_layout.tsx rename to TinyBite/app/(app)/(tabs)/_layout.tsx diff --git a/TinyBite/app/(tabs)/chat.tsx b/TinyBite/app/(app)/(tabs)/chat.tsx similarity index 81% rename from TinyBite/app/(tabs)/chat.tsx rename to TinyBite/app/(app)/(tabs)/chat.tsx index 2a4f8b2..0143fe1 100644 --- a/TinyBite/app/(tabs)/chat.tsx +++ b/TinyBite/app/(app)/(tabs)/chat.tsx @@ -1,10 +1,17 @@ import ChatCard from "@/components/chat/ChatCard"; -import { useGetOnetoOneRoomListQuery } from "@/hooks/queries/useChatRoom"; +import { + useGetGroupRoomListQuery, + useGetOnetoOneRoomListQuery, +} from "@/hooks/queries/useChatRoom"; import { colors } from "@/styles/colors"; import { textStyles } from "@/styles/typography/textStyles"; -import { OneToOneChatCardSchema } from "@/types/chat.types"; +import { + FilterTab, + GroupChatCardSchema, + OneToOneChatCardSchema, +} from "@/types/chat.types"; import { useFocusEffect } from "expo-router"; -import { useCallback, useState } from "react"; +import { useCallback, useMemo, useState } from "react"; import { FlatList, Image, @@ -15,10 +22,24 @@ import { } from "react-native"; import { SafeAreaView } from "react-native-safe-area-context"; +// 필터 탭 목록 +const filters: FilterTab[] = ["전체", "참여중인 파티", "1:1 채팅"]; + +/** + * 채팅 아이템 렌더링 함수 + */ +const renderChatItem = ({ + item, +}: { + item: OneToOneChatCardSchema | GroupChatCardSchema; +}) => { + return ; +}; + /** - * 필터 탭 타입 정의 + * 아이템 구분선 컴포넌트 (80% 너비) */ -type FilterTab = "전체" | "참여중인 파티" | "1:1 채팅"; +const ItemSeparator = () => ; /** * 채팅 화면 컴포넌트 @@ -27,51 +48,31 @@ type FilterTab = "전체" | "참여중인 파티" | "1:1 채팅"; * - 채팅 리스트: 사용자별 채팅 아이템 표시 */ export default function ChatScreen() { - const { data: onetoOneRoomList = [], refetch } = - useGetOnetoOneRoomListQuery(); + const [selectedFilter, setSelectedFilter] = useState("전체"); + + const groupQuery = useGetGroupRoomListQuery(); + const oneToOneQuery = useGetOnetoOneRoomListQuery(); useFocusEffect( useCallback(() => { - refetch(); - }, [refetch]) + groupQuery.refetch(); + oneToOneQuery.refetch(); + }, []) ); - // 선택된 필터 탭 상태 - const [selectedFilter, setSelectedFilter] = useState("전체"); - - // 필터 탭 목록 - const filters: FilterTab[] = ["전체", "참여중인 파티", "1:1 채팅"]; + const mergedData = useMemo(() => { + return [...(groupQuery.data ?? []), ...(oneToOneQuery.data ?? [])]; + }, [groupQuery.data, oneToOneQuery.data]); /** * 필터에 맞는 데이터 필터링 */ - const filteredData: OneToOneChatCardSchema[] = onetoOneRoomList.filter( - (item) => { + const filteredData: (OneToOneChatCardSchema | GroupChatCardSchema)[] = + mergedData.filter((item) => { if (selectedFilter === "전체") return true; + if (selectedFilter === "참여중인 파티") return item.roomType === "GROUP"; if (selectedFilter === "1:1 채팅") return item.roomType === "ONE_TO_ONE"; - if (selectedFilter === "참여중인 파티") return item.roomType === "Group"; - return true; - } - ); - // .map((item) => ({ - // id: item.chatRoomId, - // lastMessage: item.recentMessage, - // timestamp: item.recentTime, - // targetName: item.partyTitle || "", // Add missing required property - // ...item, - // })); - - /** - * 채팅 아이템 렌더링 함수 - */ - const renderChatItem = ({ item }: { item: OneToOneChatCardSchema }) => { - return ; - }; - - /** - * 아이템 구분선 컴포넌트 (80% 너비) - */ - const ItemSeparator = () => ; + }); return ( @@ -156,6 +157,7 @@ const styles = StyleSheet.create({ flex: 1, backgroundColor: colors.background, }, + // 상단 SafeArea 배경색 (메인 색상) safeAreaTop: { backgroundColor: colors.main, @@ -224,8 +226,7 @@ const styles = StyleSheet.create({ separator: { height: 1, backgroundColor: colors.gray[4], - marginLeft: "10%", - marginRight: "10%", + marginHorizontal: 20, }, // 빈 상태 컨테이너 emptyContainer: { diff --git a/TinyBite/app/(tabs)/index.tsx b/TinyBite/app/(app)/(tabs)/index.tsx similarity index 100% rename from TinyBite/app/(tabs)/index.tsx rename to TinyBite/app/(app)/(tabs)/index.tsx diff --git a/TinyBite/app/(tabs)/mypage.tsx b/TinyBite/app/(app)/(tabs)/mypage.tsx similarity index 100% rename from TinyBite/app/(tabs)/mypage.tsx rename to TinyBite/app/(app)/(tabs)/mypage.tsx diff --git a/TinyBite/app/camera.tsx b/TinyBite/app/(app)/camera.tsx similarity index 100% rename from TinyBite/app/camera.tsx rename to TinyBite/app/(app)/camera.tsx diff --git a/TinyBite/app/chat/[id].tsx b/TinyBite/app/(app)/chat/[id].tsx similarity index 85% rename from TinyBite/app/chat/[id].tsx rename to TinyBite/app/(app)/chat/[id].tsx index 3d67a59..b2194ae 100644 --- a/TinyBite/app/chat/[id].tsx +++ b/TinyBite/app/(app)/chat/[id].tsx @@ -1,5 +1,5 @@ import { getPrevMessage } from "@/api/chatApi"; -import ChatMessageComponent from "@/components/chat/message/ChatMessage"; +import ChatMessage from "@/components/chat/message/ChatMessage"; import ChatRoomLayout from "@/components/layout/ChatRoomLayout"; import { useChatMessages } from "@/hooks/useChatMessages"; import { websocketClient } from "@/lib/websocket/websocketClient"; @@ -8,7 +8,7 @@ import { useQuery } from "@tanstack/react-query"; import { useLocalSearchParams } from "expo-router"; import * as SecureStore from "expo-secure-store"; import { useEffect } from "react"; -import { FlatList } from "react-native"; +import { FlatList, Text } from "react-native"; export default function ChatRoomScreen() { const { id: chatRoomId } = useLocalSearchParams<{ @@ -16,8 +16,6 @@ export default function ChatRoomScreen() { }>(); const user = useAuthStore((state) => state.user); - const userId = user?.userId; - const nickname = user?.nickname; const { data, isLoading, error, refetch } = useQuery({ queryKey: ["chatMessages", chatRoomId], @@ -48,10 +46,12 @@ export default function ChatRoomScreen() { useChatMessages({ chatRoomId: chatRoomId ? parseInt(chatRoomId) : 0, initialMessages: data?.messages, - userId: userId || 0, - nickname: nickname || "", + userId: user?.userId || 0, + nickname: user?.nickname || "", }); + if (!user) return Loading...; + return ( item.messageId.toString()} - renderItem={({ item }) => } + renderItem={({ item }) => ( + + )} /> ); diff --git a/TinyBite/app/gallery-preview.tsx b/TinyBite/app/(app)/gallery-preview.tsx similarity index 100% rename from TinyBite/app/gallery-preview.tsx rename to TinyBite/app/(app)/gallery-preview.tsx diff --git a/TinyBite/app/party-detail/[id].tsx b/TinyBite/app/(app)/party-detail/[id].tsx similarity index 96% rename from TinyBite/app/party-detail/[id].tsx rename to TinyBite/app/(app)/party-detail/[id].tsx index 17d7c32..55499f6 100644 --- a/TinyBite/app/party-detail/[id].tsx +++ b/TinyBite/app/(app)/party-detail/[id].tsx @@ -43,9 +43,7 @@ export default function PartyDetailScreen() { const queryClient = useQueryClient(); const { id } = useLocalSearchParams<{ id: string }>(); const partyId = id ? parseInt(id, 10) : 0; - const setInitialPartyInfo = useEditPartyStore( - (state) => state.setInitialPartyInfo - ); + const setOriginalInfo = useEditPartyStore((state) => state.setOriginalInfo); // 현재 로그인한 사용자 정보 조회 const { user } = useAuthStore(); @@ -93,21 +91,12 @@ export default function PartyDetailScreen() { // 데이터를 성공적으로 받아왔을 시 Store 업데이트 (렌더링 중 업데이트 방지) useEffect(() => { if (isSuccess && partyDetail) { - setInitialPartyInfo(partyDetail); + setOriginalInfo(partyDetail); } - }, [isSuccess, partyDetail, setInitialPartyInfo]); - - // 로딩 중일 때 - if (isLoading) { - return ( - - - - ); - } + }, [isSuccess, partyDetail, setOriginalInfo]); // 에러 발생 시 - if (error || !partyDetail) { + if (error && !isLoading) { return ( + + + ); + } + return ( <> {/* 상태바 스타일: 헤더가 나타나면 dark, 아니면 light */} @@ -158,7 +156,7 @@ export default function PartyDetailScreen() { queryClient.invalidateQueries({ queryKey: ["getHostingParties"], }); - router.replace("/(tabs)"); + router.replace("/(app)/(tabs)"); return true; } catch (error: any) { // 400(이미 참여자가 있음 / 권한 없음) 상태 코드인 경우 에러 로그 출력하지 않음 @@ -225,7 +223,7 @@ export default function PartyDetailScreen() { detailPartyId={partyId} isClosed={partyDetail?.isClosed} isParticipating={partyDetail?.isParticipating} - pricePerPerson={partyDetail?.pricePerPerson} + groupChatRoomId={partyDetail?.groupChatRoomId} /> diff --git a/TinyBite/app/party/create/[type].tsx b/TinyBite/app/(app)/party/create/[type].tsx similarity index 92% rename from TinyBite/app/party/create/[type].tsx rename to TinyBite/app/(app)/party/create/[type].tsx index 4d13d9b..d2dfae8 100644 --- a/TinyBite/app/party/create/[type].tsx +++ b/TinyBite/app/(app)/party/create/[type].tsx @@ -89,7 +89,14 @@ export default function PartyCreateScreen() { mutationFn: postCreateParty, onSuccess: (data) => { resetCreateParty(); - router.replace("/(tabs)"); + router.replace("/(app)/(tabs)"); + Toast.show({ + type: "basicToast", + props: { text: "파티가 생성되었습니다." }, + position: "bottom", + bottomOffset: 133, + visibilityTime: 2000, + }); }, onError: (error: AxiosError) => { if (error.response?.data) { @@ -127,6 +134,17 @@ export default function PartyCreateScreen() { }; const showCorrectLinkToast = () => { + if (!isValid) { + Toast.show({ + type: "basicToast", + props: { text: "필수 값을 채워주세요." }, + position: "bottom", + bottomOffset: 133, + visibilityTime: 2000, + }); + return; + } + Toast.show({ type: "basicToast", props: { text: "올바른 URL 형식으로 입력해주세요." }, @@ -154,9 +172,9 @@ export default function PartyCreateScreen() { totalPrice: Number(totalAmount), maxParticipants: numberOfPeople, pickupLocation: { - place: pickUpLocation.place, - pickupLatitude: pickUpLocation.pickupLatitude, - pickupLongitude: pickUpLocation.pickupLongitude, + place: pickUpLocation!.place, + pickupLatitude: pickUpLocation!.pickupLatitude, + pickupLongitude: pickUpLocation!.pickupLongitude, }, ...(photoStringList && { images: photoStringList }), ...(productLink && { productLink }), @@ -170,7 +188,7 @@ export default function PartyCreateScreen() { <> - + diff --git a/TinyBite/app/party/edit/[type].tsx b/TinyBite/app/(app)/party/edit/[type].tsx similarity index 89% rename from TinyBite/app/party/edit/[type].tsx rename to TinyBite/app/(app)/party/edit/[type].tsx index af0c884..540af0a 100644 --- a/TinyBite/app/party/edit/[type].tsx +++ b/TinyBite/app/(app)/party/edit/[type].tsx @@ -16,6 +16,7 @@ import { useMutation, useQueryClient } from "@tanstack/react-query"; import { AxiosError } from "axios"; import { router, useLocalSearchParams } from "expo-router"; import { StatusBar } from "expo-status-bar"; +import { useEffect } from "react"; import { FlatList, Pressable, StyleSheet, View } from "react-native"; import { KeyboardAwareScrollView } from "react-native-keyboard-controller"; import { SafeAreaView } from "react-native-safe-area-context"; @@ -58,6 +59,7 @@ export default function PartyCreateScreen() { pickupLocation, description, productLink, + setInitialPartyInfo, setPartyTitle, setTotalAmount, setPickUpLocation, @@ -74,6 +76,7 @@ export default function PartyCreateScreen() { pickupLocation: state.pickupLocation, description: state.description, productLink: state.productLink, + setInitialPartyInfo: state.setInitialPartyInfo, setPartyTitle: state.setPartyTitle, setTotalAmount: state.setTotalAmount, setPickUpLocation: state.setPickUpLocation, @@ -121,6 +124,10 @@ export default function PartyCreateScreen() { }, }); + useEffect(() => { + setInitialPartyInfo(); + }, [setInitialPartyInfo]); + const renderItem = ({ item }: { item: Photo | PhotoUrl }) => { return ; }; @@ -138,7 +145,30 @@ export default function PartyCreateScreen() { } }; + const isValid = (): boolean => { + if ( + title.value && + totalPrice.value && + maxParticipants.value && + pickupLocation.value + ) { + return true; + } + return false; + }; + const onClickEditParty = async () => { + if (!isValid) { + Toast.show({ + type: "basicToast", + props: { text: "필수 값을 채워주세요." }, + position: "bottom", + bottomOffset: 133, + visibilityTime: 2000, + }); + return; + } + if (productLink.isEdited && !isValidLink(productLink.value)) { Toast.show({ type: "basicToast", @@ -179,11 +209,6 @@ export default function PartyCreateScreen() { // 필수 필드 images: allImageUrls, description: description, - pickupLocation: { - place: pickupLocation.place, - pickupLatitude: pickupLocation.pickupLatitude, - pickupLongitude: pickupLocation.pickupLongitude, - }, }; if (title.isEdited) { @@ -198,6 +223,13 @@ export default function PartyCreateScreen() { if (productLink.isEdited) { body.productLink = productLink.value; } + if (pickupLocation.isEdited && pickupLocation.value) { + body.pickupLocation = { + place: pickupLocation.value.place, + pickupLatitude: pickupLocation.value.pickupLatitude, + pickupLongitude: pickupLocation.value.pickupLongitude, + }; + } await EditPartyMutation.mutateAsync({ partyId: partyId, @@ -212,7 +244,7 @@ export default function PartyCreateScreen() { <> - + @@ -305,7 +337,11 @@ export default function PartyCreateScreen() { )} - + diff --git a/TinyBite/app/search/location/index.tsx b/TinyBite/app/(app)/search/location/index.tsx similarity index 94% rename from TinyBite/app/search/location/index.tsx rename to TinyBite/app/(app)/search/location/index.tsx index e3210bc..c44e00a 100644 --- a/TinyBite/app/search/location/index.tsx +++ b/TinyBite/app/(app)/search/location/index.tsx @@ -77,12 +77,16 @@ export default function PartyPlaceSearch() { }; const onPressDone = () => { - const locationData: PickupLocation = { - place: selectedItem?.place_name!, - pickupLatitude: parseFloat(selectedItem?.y!), - pickupLongitude: parseFloat(selectedItem?.x!), - }; - setPickUpLocation(locationData); + if (selectedItem) { + const locationData: PickupLocation = { + place: selectedItem.place_name, + pickupLatitude: parseFloat(selectedItem.y), + pickupLongitude: parseFloat(selectedItem.x), + }; + setPickUpLocation(locationData); + } else { + setPickUpLocation(null); + } router.back(); }; diff --git a/TinyBite/app/search/search.tsx b/TinyBite/app/(app)/search/search.tsx similarity index 98% rename from TinyBite/app/search/search.tsx rename to TinyBite/app/(app)/search/search.tsx index 25de3eb..77457e2 100644 --- a/TinyBite/app/search/search.tsx +++ b/TinyBite/app/(app)/search/search.tsx @@ -7,7 +7,7 @@ import SearchResultList from "@/components/search/SearchResultList"; import { useUserCoords } from "@/hooks/useUserCoords"; import { usePartyStore } from "@/stores/partyStore"; import { colors } from "@/styles/colors"; -import { PartyItem } from "@/types/party"; +import { PartyItem } from "@/types/party.types"; import { useInfiniteQuery, useQuery } from "@tanstack/react-query"; import { useRouter } from "expo-router"; import { StatusBar } from "expo-status-bar"; diff --git a/TinyBite/app/(auth)/login/login.tsx b/TinyBite/app/(auth)/login/login.tsx index 984a506..ddc0250 100644 --- a/TinyBite/app/(auth)/login/login.tsx +++ b/TinyBite/app/(auth)/login/login.tsx @@ -7,10 +7,12 @@ import { ApiError } from "@/types/api.types"; import { getErrorMessage } from "@/utils/getErrorMessage"; import { useMutation } from "@tanstack/react-query"; import { AxiosError } from "axios"; -import { useRouter } from "expo-router"; +import { useFocusEffect, useRouter } from "expo-router"; import * as SecureStore from "expo-secure-store"; import { StatusBar } from "expo-status-bar"; +import { useCallback } from "react"; import { + BackHandler, Image, Platform, StyleSheet, @@ -36,7 +38,7 @@ export default function LoginScreen() { if (data.signup) { login(data); await SecureStore.deleteItemAsync("googleIdToken"); - router.dismissTo("/(tabs)"); + router.dismissTo("/(app)/(tabs)"); } else { router.push("/(auth)/signup/terms"); } @@ -57,6 +59,22 @@ export default function LoginScreen() { }, }); + useFocusEffect( + useCallback(() => { + const onBackPress = () => { + BackHandler.exitApp(); // 앱 종료 + return true; // 기본 동작 막기 + }; + + const subscription = BackHandler.addEventListener( + "hardwareBackPress", + onBackPress + ); + + return () => subscription.remove(); + }, []) + ); + const handleGoogleLogin = async () => { await signOut(); diff --git a/TinyBite/app/(auth)/signup/complete.tsx b/TinyBite/app/(auth)/signup/complete.tsx index a14445d..50c0179 100644 --- a/TinyBite/app/(auth)/signup/complete.tsx +++ b/TinyBite/app/(auth)/signup/complete.tsx @@ -24,7 +24,7 @@ export default function CompleteScreen() { {/* 다음 버튼 */} router.replace("/(tabs)")} + onPress={() => router.replace("/(app)/(tabs)")} > 내 동네 파티 목록 확인하기 diff --git a/TinyBite/app/(auth)/signup/region.tsx b/TinyBite/app/(auth)/signup/region.tsx index a93a8c1..cb92a46 100644 --- a/TinyBite/app/(auth)/signup/region.tsx +++ b/TinyBite/app/(auth)/signup/region.tsx @@ -1,5 +1,5 @@ import { getLocationName, postSignupGoogle } from "@/api/authApi"; -import { PlaceItem } from "@/app/search/location"; +import { PlaceItem } from "@/app/(app)/search/location"; import PaginationIndecatorHeader from "@/components/PaginationIndecatorHeader"; import { useUserCoords } from "@/hooks/useUserCoords"; import { useAuthStore } from "@/stores/authStore"; diff --git a/TinyBite/app/_layout.tsx b/TinyBite/app/_layout.tsx index 1457b60..ee0ea30 100644 --- a/TinyBite/app/_layout.tsx +++ b/TinyBite/app/_layout.tsx @@ -24,8 +24,13 @@ export default function RootLayout() { - - + + + + + + + diff --git a/TinyBite/app/index.tsx b/TinyBite/app/index.tsx index a5b2792..26404d6 100644 --- a/TinyBite/app/index.tsx +++ b/TinyBite/app/index.tsx @@ -68,7 +68,7 @@ export default function Index() { // 5. 유저 있음 → 저장 후 메인 if (user) { setUser(user); - router.replace("/(tabs)"); + router.replace("/(app)/(tabs)"); } }; diff --git a/TinyBite/components/ChatRoomHeader.tsx b/TinyBite/components/ChatRoomHeader.tsx index 359d609..ff42ae6 100644 --- a/TinyBite/components/ChatRoomHeader.tsx +++ b/TinyBite/components/ChatRoomHeader.tsx @@ -2,11 +2,12 @@ import OneOnOneChatStatusTag from "@/components/chat/OneOnOneChatStatusTag"; import { colors } from "@/styles/colors"; import { textStyles } from "@/styles/typography/textStyles"; import { + GroupChatDetailSchema, + GroupChatStatusType, + OneToOneChatDetailSchema, OneToOneChatStatusType, - PartyStatusType, - RoomType, } from "@/types/chat.types"; -import { router, useLocalSearchParams } from "expo-router"; +import { router } from "expo-router"; import { Image, StyleSheet, Text, TouchableOpacity, View } from "react-native"; import { SafeAreaView } from "react-native-safe-area-context"; import PartyStatusTag from "./chat/PartyStatusTag"; @@ -14,10 +15,12 @@ import PartyStatusTag from "./chat/PartyStatusTag"; const CHEVRON_LEFT_ICON = require("@/assets/images/chevron/chevron-left-36-gray.png"); const MEMBER_COUNT = require("@/assets/images/chat/member-count.png"); -const ChatRoomHeader = () => { - const { roomType } = useLocalSearchParams<{ - roomType: RoomType; - }>(); +interface ChatRoomHeaderProps { + roomDetail: OneToOneChatDetailSchema | GroupChatDetailSchema; +} + +const ChatRoomHeader = ({ roomDetail }: ChatRoomHeaderProps) => { + // const data = roomDetail.roomType === 'ONE_TO_ONE' ? roomDetail as OneToOneChatDetailSchema : roomDetail as GroupChatDetailSchema; return ( @@ -26,7 +29,7 @@ const ChatRoomHeader = () => { - {roomType === "ONE_TO_ONE" ? ( + {roomDetail.roomType === "ONE_TO_ONE" ? ( { numberOfLines={1} ellipsizeMode="tail" > - .임시 타켓 이름. + {roomDetail.targetName} - .임시 파티 제목. + {roomDetail.partyTitle} ) : ( @@ -52,17 +57,17 @@ const ChatRoomHeader = () => { numberOfLines={1} ellipsizeMode="tail" > - .임시 파티 제목. + {roomDetail.partyTitle} - .임시 명수. + {roomDetail.currentParticipantCnt} diff --git a/TinyBite/components/CreatePartyPageHeader.tsx b/TinyBite/components/CreatePartyPageHeader.tsx index 0485ef5..a8f38e0 100644 --- a/TinyBite/components/CreatePartyPageHeader.tsx +++ b/TinyBite/components/CreatePartyPageHeader.tsx @@ -8,13 +8,22 @@ const CHEVRON_LEFT_ICON = require("@/assets/images/chevron/chevron-left-36-gray. interface CreatePartyPageHeaderProps { title: string; + action?: () => void; } -const CreatePartyPageHeader = ({ title }: CreatePartyPageHeaderProps) => { +const CreatePartyPageHeader = ({ + title, + action, +}: CreatePartyPageHeaderProps) => { return ( - router.back()}> + { + action?.(); + router.back(); + }} + > diff --git a/TinyBite/components/chat/ChatBottomPanel.tsx b/TinyBite/components/chat/ChatBottomPanel.tsx index a39f60a..3fca61b 100644 --- a/TinyBite/components/chat/ChatBottomPanel.tsx +++ b/TinyBite/components/chat/ChatBottomPanel.tsx @@ -59,7 +59,7 @@ const ChatBottomPanel = ({ // 미리보기 화면으로 이동 router.push({ - pathname: "/gallery-preview", + pathname: "/(app)/gallery-preview", params: { uri: selectedImage.uri, fileName: fileName, @@ -81,7 +81,7 @@ const ChatBottomPanel = ({ // 카메라 화면으로 이동 setIsPanelVisible(false); - router.push("/camera"); + router.push("/(app)/camera"); }; useEffect(() => { @@ -134,7 +134,7 @@ const ChatBottomPanel = ({ const { granted } = await requestPermission(); setShowCameraModal(false); if (granted) { - router.push("/camera"); + router.push("/(app)/camera"); } }} /> diff --git a/TinyBite/components/chat/ChatCard.tsx b/TinyBite/components/chat/ChatCard.tsx index b584fb4..52c834a 100644 --- a/TinyBite/components/chat/ChatCard.tsx +++ b/TinyBite/components/chat/ChatCard.tsx @@ -1,18 +1,19 @@ import { colors } from "@/styles/colors"; import { textStyles } from "@/styles/typography/textStyles"; import { + GroupChatCardSchema, + GroupChatStatusType, OneToOneChatCardSchema, OneToOneChatStatusType, - PartyStatusType, } from "@/types/chat.types"; import { useRouter } from "expo-router"; -import { StyleSheet, Text, TouchableOpacity, View } from "react-native"; +import { Image, StyleSheet, Text, TouchableOpacity, View } from "react-native"; import ChatCardImage from "./ChatCardImage"; import OneOnOneChatStatusTag from "./OneOnOneChatStatusTag"; import PartyStatusTag from "./PartyStatusTag"; interface ChatItemProps { - item: OneToOneChatCardSchema; + item: OneToOneChatCardSchema | GroupChatCardSchema; } /** @@ -27,15 +28,18 @@ const ChatItem = ({ item }: ChatItemProps) => { // 채팅 타입 확인 (1:1 채팅인지 파티 채팅인지) const isOneOnOne = item.roomType === "ONE_TO_ONE"; + const oneToOneItem = item as OneToOneChatCardSchema; + const groupItem = item as GroupChatCardSchema; + const handleChatPress = () => { router.navigate({ - pathname: "/chat/[id]", + pathname: "/(app)/chat/[id]", params: { id: item.chatRoomId, roomType: item.roomType, - status: item.status, - partyTitle: item.partyTitle, - targetName: item.targetName, + // status: item.status, + // partyTitle: item.partyTitle, + // targetName: item.targetName, }, }); }; @@ -57,7 +61,7 @@ const ChatItem = ({ item }: ChatItemProps) => { ellipsizeMode="tail" > {/* 1:1 채팅은 사용자 이름, 파티 채팅은 파티 제목 표시 */} - {isOneOnOne ? item.targetName : item.partyTitle} + {isOneOnOne ? oneToOneItem.targetName : groupItem.partyTitle} {item.recentTime} @@ -88,28 +92,29 @@ const ChatItem = ({ item }: ChatItemProps) => { {/* 태그 영역: 상태 태그 + 파티 제목(1:1) / 인원수(파티) */} {/* 상태 태그: RoomType에 따라 적절한 태그 컴포넌트 사용 */} - {item.status && - (isOneOnOne ? ( - // 1:1 채팅 상태 태그 (승인 대기, 승인 거절, 승인 완료, 승인 요청, 파티 종료) - - ) : ( - // 파티 채팅 상태 태그 (모집 중, 진행 중, 파티 종료) - - ))} + {isOneOnOne ? ( + // 1:1 채팅 상태 태그 (승인 대기, 승인 거절, 승인 완료, 승인 요청, 파티 종료) + + ) : ( + // 파티 채팅 상태 태그 (모집 중, 진행 중, 파티 종료) + + )} {/* 1:1 채팅일 때 파티 제목 표시 */} - {isOneOnOne && item.partyTitle && ( + {isOneOnOne && ( - {item.partyTitle} + {oneToOneItem.partyTitle} )} {/* 파티 채팅일 때 인원수 표시 */} - {/* {!isOneOnOne && item.memberCount !== undefined && ( + {!isOneOnOne && ( { resizeMode="contain" /> - {item.memberCount} + {groupItem.currentParticipantCnt} - )} */} + )} @@ -140,6 +145,7 @@ const styles = StyleSheet.create({ // 프로필 이미지 컨테이너 profileContainer: { marginRight: 16, + justifyContent: "center", }, // 채팅 컨텐츠 영역 chatContent: { @@ -150,11 +156,12 @@ const styles = StyleSheet.create({ flexDirection: "row", justifyContent: "space-between", alignItems: "center", + gap: 4, }, // 사용자 이름 / 파티 제목 userName: { + flexShrink: 1, color: colors.black, - maxWidth: "80%", }, // 타임스탬프 timestamp: { diff --git a/TinyBite/components/chat/ChatCardImage.tsx b/TinyBite/components/chat/ChatCardImage.tsx index 6b7d1b9..2ddcedc 100644 --- a/TinyBite/components/chat/ChatCardImage.tsx +++ b/TinyBite/components/chat/ChatCardImage.tsx @@ -1,18 +1,22 @@ import { colors } from "@/styles/colors"; -import { OneToOneChatCardSchema, PartyCategoryType } from "@/types/chat.types"; +import { + GroupChatCardSchema, + OneToOneChatCardSchema, +} from "@/types/chat.types"; +import { parseProfileImage } from "@/utils/parseProfileImage"; import { Image, StyleSheet, View } from "react-native"; /** * 카테고리별 아이콘 매핑 */ -const categoryIcons: Record = { - delivery: require("@/assets/images/main/category/delivery.png"), - grocery: require("@/assets/images/main/category/grocery.png"), - essentials: require("@/assets/images/main/category/essentials.png"), -}; +// const categoryIcons: Record = { +// delivery: require("@/assets/images/main/category/delivery.png"), +// grocery: require("@/assets/images/main/category/grocery.png"), +// essentials: require("@/assets/images/main/category/essentials.png"), +// }; interface ChatItemImageProps { - item: OneToOneChatCardSchema; + item: OneToOneChatCardSchema | GroupChatCardSchema; } /** @@ -24,13 +28,17 @@ const ChatItemImage = ({ item }: ChatItemImageProps) => { const isOneOnOne = item.roomType === "ONE_TO_ONE"; if (isOneOnOne) { + const oneToOneItem = item as OneToOneChatCardSchema; + return ( {/* 상대방 프로필 이미지 (왼쪽) */} - {item.targetProfileImage ? ( + {oneToOneItem.targetProfileImage ? ( @@ -40,9 +48,11 @@ const ChatItemImage = ({ item }: ChatItemImageProps) => { {/* 내 프로필 이미지 (오른쪽, 겹침) */} - {item.myProfileImage ? ( + {oneToOneItem.myProfileImage ? ( @@ -54,27 +64,28 @@ const ChatItemImage = ({ item }: ChatItemImageProps) => { ); } - // return ( - // - // {item.partyImage ? ( - // - // ) : ( - // - // {item.category && categoryIcons[item.category] ? ( - // - // ) : null} - // - // )} - // - // ); + const groupItem = item as GroupChatCardSchema; + return ( + + {groupItem.partyImage ? ( + + ) : ( + + {/* {groupItem.category && categoryIcons[groupItem.category] ? ( + + ) : null} */} + + )} + + ); }; export default ChatItemImage; diff --git a/TinyBite/components/chat/ChatRoomStatusHandler.tsx b/TinyBite/components/chat/ChatRoomStatusHandler.tsx new file mode 100644 index 0000000..9a54c4e --- /dev/null +++ b/TinyBite/components/chat/ChatRoomStatusHandler.tsx @@ -0,0 +1,153 @@ +import { + useApproveJoinPartyMutation, + useRejectJoinPartyMutation, +} from "@/hooks/mutations/useChat"; +import { + GroupChatDetailSchema, + OneToOneChatDetailSchema, +} from "@/types/chat.types"; +import { ChatJoinRequestCard } from "./host/ChatJoinRequestCard"; +import ChatPartyClosureCard from "./host/ChatPartyClosureCard"; +import { ChatRecruitmentCloseCard } from "./host/ChatRecruitmentCloseCard"; +import { ChatJoinAcceptedCard } from "./participant/ChatJoinAcceptedCard"; +import { ChatJoinPendingCard } from "./participant/ChatJoinPendingCard"; +import ChatPartyProgressCard from "./participant/ChatPartyProgressCard"; + +// type participantType = 'HOST' | 'PARTICIPANT'; +// type OneToOneChatStatusType = 'PENDING' | 'REJECTED' | 'APPROVED' | 'REQUESTED' | 'ENDED'; +// type GroupChatStatusType = "RECRUITING" | "COMPLETED" | "CLOSED" | "CANCELLED"; + +interface ChatRoomStatusHandlerProps { + chatDetail: OneToOneChatDetailSchema | GroupChatDetailSchema; +} + +const ChatRoomStatusHandler = ({ chatDetail }: ChatRoomStatusHandlerProps) => { + const approveMutation = useApproveJoinPartyMutation( + chatDetail.roomType === "ONE_TO_ONE" ? chatDetail.partyId : undefined, + chatDetail.roomType === "ONE_TO_ONE" ? chatDetail.participantId : undefined, + chatDetail.roomType === "ONE_TO_ONE" ? chatDetail.chatRoomId : undefined + ); + + const rejectMutation = useRejectJoinPartyMutation( + chatDetail.roomType === "ONE_TO_ONE" ? chatDetail.partyId : undefined, + chatDetail.roomType === "ONE_TO_ONE" ? chatDetail.participantId : undefined, + chatDetail.roomType === "ONE_TO_ONE" ? chatDetail.chatRoomId : undefined + ); + + // GROUP인 경우 + if (chatDetail.roomType === "GROUP") { + // HOST 분기 + if (chatDetail.participantType === "HOST") { + if (chatDetail.status === "RECRUITING") { + return ( + + ); + } + + if (chatDetail.status === "COMPLETED") { + return ( + + ); + } + + if (chatDetail.status === "CLOSED" || chatDetail.status === "CANCELLED") { + return null; + } + + // 예상치 못한 status + alert(`HOST에게 예상치 못한 status: ${chatDetail.status}`); + return null; + } + + // PARTICIPANT 분기 + if (chatDetail.participantType === "PARTICIPANT") { + return ( + + ); + } + + // 예상치 못한 participantType + alert(`예상치 못한 participantType: ${chatDetail.participantType}`); + return null; + } + + // ONE_TO_ONE인 경우 + const { participantType, participantStatus } = chatDetail; + + const handleApprove = () => { + if (chatDetail.roomType !== "ONE_TO_ONE") return; + approveMutation.mutate(); + }; + + const handleReject = () => { + if (chatDetail.roomType !== "ONE_TO_ONE") return; + rejectMutation.mutate(); + }; + + // HOST 분기 + if (participantType === "HOST") { + if (participantStatus === "REQUESTED") { + // 1:1 파티장 - 수락, 거절 + return ( + + ); + } + + if (participantStatus === "REJECTED" || participantStatus === "APPROVED") { + return null; + } + + // 예상치 못한 status + alert(`HOST에게 예상치 못한 status: ${participantStatus}`); + return null; + } + + // PARTICIPANT 분기 + if (participantType === "PARTICIPANT") { + if (participantStatus === "PENDING") { + // 1:1 참여자 - 대기 + return ; + } + + if (participantStatus === "APPROVED") { + // 1:1 참여자 - 수락됨 + if (chatDetail.groupChatRoomId === undefined) { + return null; + } + return ; + } + + if (participantStatus === "REJECTED") { + return null; + } + + // 예상치 못한 status + alert(`PARTICIPANT에게 예상치 못한 status: ${participantStatus}`); + return null; + } + + // 예상치 못한 participantType + alert(`예상치 못한 participantType: ${participantType}`); + return null; +}; + +export default ChatRoomStatusHandler; diff --git a/TinyBite/components/chat/PartyStatusTag.tsx b/TinyBite/components/chat/PartyStatusTag.tsx index 47f84e2..90f2c71 100644 --- a/TinyBite/components/chat/PartyStatusTag.tsx +++ b/TinyBite/components/chat/PartyStatusTag.tsx @@ -1,10 +1,13 @@ import { colors } from "@/styles/colors"; import { textStyles } from "@/styles/typography/textStyles"; -import { PartyStatusType } from "@/types/chat.types"; +import { + GroupChatStatusLabelMap, + GroupChatStatusType, +} from "@/types/chat.types"; import { StyleSheet, Text, View } from "react-native"; export interface PartyStatusTagProps { - status: PartyStatusType; + status: GroupChatStatusType; } /** @@ -16,21 +19,29 @@ export interface PartyStatusTagProps { const PartyStatusTag = ({ status }: PartyStatusTagProps) => { // 상태별 스타일을 객체로 정의 const statusStyles = { - "모집 중": { + RECRUITING: { + // 모집 중 container: styles.statusTagRecruiting, text: styles.statusTagTextRecruiting, }, - "진행 중": { + COMPLETED: { + // 진행 중 container: styles.statusTagOngoing, text: styles.statusTagTextOngoing, }, - "파티 종료": { + CLOSED: { + // 파티 종료 container: styles.statusTagEnded, text: styles.statusTagTextEnded, }, + CANCELLED: { + // 파티 취소 + container: styles.statusTagCancelled, + text: styles.statusTagTextCancelled, + }, }; - const currentStyle = statusStyles[status] || statusStyles["모집 중"]; + const currentStyle = statusStyles[status] || statusStyles["RECRUITING"]; return ( @@ -41,7 +52,7 @@ const PartyStatusTag = ({ status }: PartyStatusTagProps) => { currentStyle.text, ]} > - {status} + {GroupChatStatusLabelMap[status]} ); @@ -68,6 +79,10 @@ const styles = StyleSheet.create({ statusTagEnded: { backgroundColor: colors.gray[4], }, + // 파티 취소 상태 태그 + statusTagCancelled: { + backgroundColor: colors.red[1], + }, // 상태 태그 텍스트 기본 스타일 statusTagText: {}, // 모집 중 상태 태그 텍스트 @@ -82,4 +97,8 @@ const styles = StyleSheet.create({ statusTagTextEnded: { color: colors.gray[1], }, + // 파티 취소 상태 태그 텍스트 + statusTagTextCancelled: { + color: colors.red[1], + }, }); diff --git a/TinyBite/components/chat/host/ChatPartyClosureCard.tsx b/TinyBite/components/chat/host/ChatPartyClosureCard.tsx index 98794bd..30f5497 100644 --- a/TinyBite/components/chat/host/ChatPartyClosureCard.tsx +++ b/TinyBite/components/chat/host/ChatPartyClosureCard.tsx @@ -1,17 +1,36 @@ import ConfirmModal from "@/components/ConfirmModal"; +import { useSettlePartyMutation } from "@/hooks/mutations/useChat"; import { colors } from "@/styles/colors"; import { textStyles } from "@/styles/typography/textStyles"; +import { useQueryClient } from "@tanstack/react-query"; import React, { useState } from "react"; import { Image, StyleSheet, Text, TouchableOpacity, View } from "react-native"; const GROUP_ICON = require("@/assets/images/chat/group-icon.png"); const BRAND_ICON = require("@/assets/images/chat/brand-logo-50.png"); -const ChatPartyClosureCard = () => { +interface ChatPartyClosureCardProps { + partyId: number; + groupChatRoomId: number; +} + +const ChatPartyClosureCard = ({ + partyId, + groupChatRoomId, +}: ChatPartyClosureCardProps) => { + const queryClient = useQueryClient(); const [showModal, setShowModal] = useState(false); - const handleCloseChatRoom = () => { - console.log("종료하기"); + const settleMutation = useSettlePartyMutation(partyId, groupChatRoomId); + + const handleCloseChatRoom = async () => { + await settleMutation.mutateAsync(); + if (settleMutation.isSuccess) { + // queryClient.invalidateQueries({ + // queryKey: ["useGetGroupRoomDetailQuery", groupChatRoomId], + // }); + queryClient.invalidateQueries(); + } }; return ( diff --git a/TinyBite/components/chat/host/ChatRecruitmentCloseCard.tsx b/TinyBite/components/chat/host/ChatRecruitmentCloseCard.tsx index 4770bde..64d9ab8 100644 --- a/TinyBite/components/chat/host/ChatRecruitmentCloseCard.tsx +++ b/TinyBite/components/chat/host/ChatRecruitmentCloseCard.tsx @@ -1,17 +1,45 @@ import ConfirmModal from "@/components/ConfirmModal"; +import { useCompletePartyMutation } from "@/hooks/mutations/useChat"; import { colors } from "@/styles/colors"; import { textStyles } from "@/styles/typography/textStyles"; +import { useQueryClient } from "@tanstack/react-query"; import React, { useState } from "react"; import { Image, StyleSheet, Text, TouchableOpacity, View } from "react-native"; const GROUP_ICON = require("@/assets/images/chat/group-icon.png"); const FIRESORKS_ICON = require("@/assets/images/chat/fireworks.png"); -export const ChatRecruitmentCloseCard = () => { +interface ChatRecruitmentCloseCardProps { + currentMembers: number; + maxMembers: number; + partyId: number; + groupChatRoomId: number; +} + +export const ChatRecruitmentCloseCard = ({ + currentMembers, + maxMembers, + partyId, + groupChatRoomId, +}: ChatRecruitmentCloseCardProps) => { + const queryClient = useQueryClient(); const [showModal, setShowModal] = useState(false); + const completeMutation = useCompletePartyMutation(partyId, groupChatRoomId); + + const handleCompleteParty = async () => { + await completeMutation.mutateAsync(); + if (completeMutation.isSuccess) { + setShowModal(true); + } + }; + const handleNavigateToChatRoom = () => { - console.log("파티 채팅방 가기"); + setShowModal(false); + // queryClient.invalidateQueries({ + // queryKey: ["useGetGroupRoomDetailQuery", groupChatRoomId], + // }); + queryClient.invalidateQueries(); }; return ( @@ -23,15 +51,12 @@ export const ChatRecruitmentCloseCard = () => { - 모집 중 (3/3명) + 모집 중 ({currentMembers}/{maxMembers}명) {/* 하단 버튼 영역 */} - setShowModal(true)} - > + 모집을 마감하고 정산하기 diff --git a/TinyBite/components/chat/message/ChatMessage.tsx b/TinyBite/components/chat/message/ChatMessage.tsx index 6237e44..d67c72e 100644 --- a/TinyBite/components/chat/message/ChatMessage.tsx +++ b/TinyBite/components/chat/message/ChatMessage.tsx @@ -8,9 +8,10 @@ import { OutgoingMessage } from "./OutgoingMessage"; interface ChatMessageProps { message: ChatMessageSchema; + userId: number; } -const ChatMessage = ({ message }: ChatMessageProps) => { +const ChatMessage = ({ message, userId }: ChatMessageProps) => { switch (message.messageType) { case "DATE": return ; @@ -19,14 +20,14 @@ const ChatMessage = ({ message }: ChatMessageProps) => { return ; case "TEXT": - return message.isMine ? ( + return message.senderId === userId ? ( ) : ( ); case "IMAGE": - return message.isMine ? ( + return message.senderId === userId ? ( ) : ( diff --git a/TinyBite/components/chat/participant/ChatJoinAcceptedCard.tsx b/TinyBite/components/chat/participant/ChatJoinAcceptedCard.tsx index 2e30fe9..255f042 100644 --- a/TinyBite/components/chat/participant/ChatJoinAcceptedCard.tsx +++ b/TinyBite/components/chat/participant/ChatJoinAcceptedCard.tsx @@ -1,13 +1,30 @@ import { colors } from "@/styles/colors"; import { textStyles } from "@/styles/typography/textStyles"; +import { router } from "expo-router"; import React from "react"; import { Image, StyleSheet, Text, TouchableOpacity, View } from "react-native"; const GROUP_ICON = require("@/assets/images/chat/group-icon.png"); -export const ChatJoinAcceptedCard = () => { +interface ChatJoinAcceptedCardProps { + chatRoomId: number; +} + +export const ChatJoinAcceptedCard = ({ + chatRoomId, +}: ChatJoinAcceptedCardProps) => { const handleNavigateToChatRoom = () => { console.log("파티 채팅방 가기"); + router.dismissTo({ + pathname: "/(app)/chat/[id]", + params: { + id: chatRoomId, + roomType: "GROUP", + // status: item.status, + // partyTitle: item.partyTitle, + // targetName: item.targetName, + }, + }); }; return ( diff --git a/TinyBite/components/chat/participant/ChatPartyProgressCard.tsx b/TinyBite/components/chat/participant/ChatPartyProgressCard.tsx index b948ee8..a188b26 100644 --- a/TinyBite/components/chat/participant/ChatPartyProgressCard.tsx +++ b/TinyBite/components/chat/participant/ChatPartyProgressCard.tsx @@ -1,5 +1,6 @@ import { colors } from "@/styles/colors"; import { textStyles } from "@/styles/typography/textStyles"; +import { GroupChatStatusType } from "@/types/chat.types"; import React, { useEffect } from "react"; import { Image, StyleSheet, Text, View } from "react-native"; import Animated, { @@ -13,10 +14,8 @@ const MEGAPHONE_ICON = require("@/assets/images/chat/notice.png"); const COIN_ICON = require("@/assets/images/chat/coin.png"); const BRAND_ICON = require("@/assets/images/chat/brand-logo-32.png"); -type PartyStatus = "모집 중" | "진행 중" | "파티 종료"; - interface ChatPartyProgressCardProps { - status: PartyStatus; + status: GroupChatStatusType; currentMembers: number; maxMembers: number; } @@ -29,13 +28,13 @@ const ChatPartyProgressCard = ({ const progress = useSharedValue(0); // 상태에 따른 진행도 설정 - const getProgressValue = (status: PartyStatus) => { + const getProgressValue = (status: GroupChatStatusType) => { switch (status) { - case "모집 중": + case "RECRUITING": return 33.33; - case "진행 중": + case "COMPLETED": return 66.66; - case "파티 종료": + case "CLOSED": return 100; default: return 0; @@ -43,23 +42,23 @@ const ChatPartyProgressCard = ({ }; // 상태에 따른 아이콘 및 텍스트 설정 - const getStatusConfig = (status: PartyStatus) => { + const getStatusConfig = (status: GroupChatStatusType) => { switch (status) { - case "모집 중": + case "RECRUITING": return { icon: MEGAPHONE_ICON, title: "파티 참여 완료!", description: "인원을 모집 중입니다.", activeStep: 2, }; - case "진행 중": + case "COMPLETED": return { icon: COIN_ICON, title: "인원 모집 완료!", description: "파티장이 정산을 준비하고 있어요.", activeStep: 3, }; - case "파티 종료": + case "CLOSED": return { icon: BRAND_ICON, title: "수령 완료!", diff --git a/TinyBite/components/layout/ChatRoomLayout.tsx b/TinyBite/components/layout/ChatRoomLayout.tsx index 784f85e..19940d5 100644 --- a/TinyBite/components/layout/ChatRoomLayout.tsx +++ b/TinyBite/components/layout/ChatRoomLayout.tsx @@ -1,12 +1,19 @@ import ChatInputBox from "@/components/chat/ChatInputBox"; import ChatRoomHeader from "@/components/ChatRoomHeader"; +import { + useGetGroupRoomDetailQuery, + useGetOnetoOneRoomDetailQuery, +} from "@/hooks/queries/useChatRoom"; import { colors } from "@/styles/colors"; +import { RoomType } from "@/types/chat.types"; +import { useLocalSearchParams, useRouter } from "expo-router"; import { StatusBar } from "expo-status-bar"; import { ReactNode, useState } from "react"; -import { StyleSheet, View } from "react-native"; +import { ActivityIndicator, Alert, StyleSheet, View } from "react-native"; import { KeyboardAvoidingView } from "react-native-keyboard-controller"; import { SafeAreaView } from "react-native-safe-area-context"; import ChatBottomPanel from "../chat/ChatBottomPanel"; +import ChatRoomStatusHandler from "../chat/ChatRoomStatusHandler"; interface ChatRoomLayoutProps { children: ReactNode; @@ -19,52 +26,88 @@ const ChatRoomLayout = ({ onSendText, onSendImage, }: ChatRoomLayoutProps) => { + const { id: chatRoomId, roomType } = useLocalSearchParams<{ + id: string; + roomType: RoomType; + }>(); + const numericChatRoomId = Number(chatRoomId); + const router = useRouter(); const [isPanelVisible, setIsPanelVisible] = useState(false); + const oneToOneQuery = useGetOnetoOneRoomDetailQuery(numericChatRoomId, { + enabled: roomType === "ONE_TO_ONE", + }); + const groupQuery = useGetGroupRoomDetailQuery(numericChatRoomId, { + enabled: roomType === "GROUP", + }); + + const isOneToOne = roomType === "ONE_TO_ONE"; + + const roomDetailData = isOneToOne ? oneToOneQuery.data : groupQuery.data; + const roomDetailIsLoading = isOneToOne + ? oneToOneQuery.isLoading + : groupQuery.isLoading; + const roomDetailIsError = isOneToOne + ? oneToOneQuery.isError + : groupQuery.isError; + const togglePanel = () => { setIsPanelVisible((prev) => !prev); }; + if (roomDetailIsLoading) { + return ( + + + + ); + } + + if (roomDetailIsError) { + Alert.alert( + "오류", + "채팅방 정보를 불러올 수 없습니다.", + [ + { + text: "확인", + onPress: () => router.back(), + }, + ], + { cancelable: false } + ); + return ( + + + + ); + } + + if (!roomDetailData) { + return null; + } + return ( <> - + - {/* 1:1 파티장 - 수락, 거절 */} - {/* console.log("승인")} - onReject={() => console.log("거절")} - /> */} - - {/* 1:1 참여자 - 대기 */} - {/* */} - - {/* 1:1 참여자 - 수락됨 */} - {/* */} - - {/* group 파티장 - 정산하기 */} - {/* */} - - {/* group 파티장 - 모집 완료 */} - {/* */} - - {/* group 참여자 - 진행상황 */} - {/* */} - + {children} diff --git a/TinyBite/components/main/FloatingMenuOverlay.tsx b/TinyBite/components/main/FloatingMenuOverlay.tsx index 7f15fe6..c485048 100644 --- a/TinyBite/components/main/FloatingMenuOverlay.tsx +++ b/TinyBite/components/main/FloatingMenuOverlay.tsx @@ -48,7 +48,7 @@ const FloatingMenuOverlay = ({ onPress={() => { setIsMenuOpen(false); router.navigate({ - pathname: "/party/create/[type]", + pathname: "/(app)/party/create/[type]", params: { type: "DELIVERY" }, }); }} @@ -69,7 +69,7 @@ const FloatingMenuOverlay = ({ onPress={() => { setIsMenuOpen(false); router.navigate({ - pathname: "/party/create/[type]", + pathname: "/(app)/party/create/[type]", params: { type: "HOUSEHOLD" }, }); }} @@ -90,7 +90,7 @@ const FloatingMenuOverlay = ({ onPress={() => { setIsMenuOpen(false); router.navigate({ - pathname: "/party/create/[type]", + pathname: "/(app)/party/create/[type]", params: { type: "GROCERY" }, }); }} diff --git a/TinyBite/components/main/MainHeader.tsx b/TinyBite/components/main/MainHeader.tsx index 5b3859e..1182311 100644 --- a/TinyBite/components/main/MainHeader.tsx +++ b/TinyBite/components/main/MainHeader.tsx @@ -78,7 +78,7 @@ const MainHeader = ({}: MainHeaderProps = {}) => { {location} - router.push("/search/search")}> + router.push("/(app)/search/search")}> { item={item} onPress={() => router.push({ - pathname: "/party-detail/[id]" as any, + pathname: "/(app)/party-detail/[id]" as any, params: { id: item.partyId.toString() }, }) } diff --git a/TinyBite/components/main/party-detail/PartyDetailCTA.tsx b/TinyBite/components/main/party-detail/PartyDetailCTA.tsx index 4bef8ec..edbb63e 100644 --- a/TinyBite/components/main/party-detail/PartyDetailCTA.tsx +++ b/TinyBite/components/main/party-detail/PartyDetailCTA.tsx @@ -1,6 +1,7 @@ import { useRequestJoinParty } from "@/hooks/mutations/useParty"; import { colors } from "@/styles/colors"; import { textStyles } from "@/styles/typography/textStyles"; +import { router } from "expo-router"; import { StyleSheet, Text, TouchableOpacity } from "react-native"; import { SafeAreaView } from "react-native-safe-area-context"; @@ -8,14 +9,14 @@ interface PartyDetailCTAProps { detailPartyId: number; isClosed?: boolean; isParticipating?: boolean; - pricePerPerson?: number; + groupChatRoomId?: number; } const PartyDetailCTA = ({ detailPartyId, isClosed, isParticipating, - pricePerPerson, + groupChatRoomId, }: PartyDetailCTAProps) => { const { mutate } = useRequestJoinParty(); @@ -26,14 +27,23 @@ const PartyDetailCTA = ({ if (isParticipating) { return "채팅방으로 이동"; } - if (pricePerPerson != null) { - return `${pricePerPerson.toLocaleString()}원으로 참여하기`; - } - return "로딩 중..."; + return "채팅으로 참여하기"; }; const handleGoToChatPress = () => { - mutate(detailPartyId); + //groupChatRoomId가 있으면 그룹 채팅방으로 이동 + if (groupChatRoomId) { + router.push({ + pathname: "/(app)/chat/[id]", + params: { + id: groupChatRoomId.toString(), + roomType: "GROUP", + }, + }); + } else { + // 참여 요청 + mutate(detailPartyId); + } }; return ( diff --git a/TinyBite/components/mypage/MyPartyList.tsx b/TinyBite/components/mypage/MyPartyList.tsx index 8c2b719..3ab0b2f 100644 --- a/TinyBite/components/mypage/MyPartyList.tsx +++ b/TinyBite/components/mypage/MyPartyList.tsx @@ -1,11 +1,12 @@ import { getActiveParties, getHostingParties } from "@/api/partyApi"; import MainCard from "@/components/main/MainCard"; +import { useUserCoords } from "@/hooks/useUserCoords"; import { colors } from "@/styles/colors"; import { textStyles } from "@/styles/typography/textStyles"; import { PartyItem } from "@/types/party.types"; import { useQuery } from "@tanstack/react-query"; import { useRouter } from "expo-router"; -import { useState } from "react"; +import { useEffect, useState } from "react"; import { Pressable, ScrollView, StyleSheet, Text, View } from "react-native"; type TabType = "active" | "hosting"; @@ -18,13 +19,22 @@ type TabType = "active" | "hosting"; const MyPartyList = () => { const router = useRouter(); const [activeTab, setActiveTab] = useState("active"); + const { coords, refresh: fetchCoords } = useUserCoords(); + + // 컴포넌트 마운트 시 위치 정보 가져오기 + useEffect(() => { + if (!coords) { + fetchCoords(); + } + }, []); // 참여중인 파티 리스트 조회 const { data: activeParties = [], isLoading: isLoadingActive } = useQuery< PartyItem[] >({ queryKey: ["getActiveParties"], - queryFn: getActiveParties, + queryFn: () => getActiveParties(coords?.latitude, coords?.longitude), + enabled: activeTab === "active", }); // 호스팅 중인 파티 리스트 조회 @@ -32,7 +42,7 @@ const MyPartyList = () => { PartyItem[] >({ queryKey: ["getHostingParties"], - queryFn: getHostingParties, + queryFn: () => getHostingParties(coords?.latitude, coords?.longitude), enabled: activeTab === "hosting", }); @@ -112,7 +122,7 @@ const MyPartyList = () => { containerStyle={styles.mypageCard} onPress={() => router.push({ - pathname: "/party-detail/[id]" as any, + pathname: "/(app)/party-detail/[id]" as any, params: { id: item.partyId.toString() }, }) } diff --git a/TinyBite/components/search/SearchResultList.tsx b/TinyBite/components/search/SearchResultList.tsx index d29c203..34230e9 100644 --- a/TinyBite/components/search/SearchResultList.tsx +++ b/TinyBite/components/search/SearchResultList.tsx @@ -1,7 +1,7 @@ import MainCard from "@/components/main/MainCard"; import { colors } from "@/styles/colors"; import { textStyles } from "@/styles/typography/textStyles"; -import { PartyItem } from "@/types/party"; +import { PartyItem } from "@/types/party.types"; import { FlatList, StyleSheet, Text, View } from "react-native"; interface SearchResultListProps { diff --git a/TinyBite/hooks/mutations/useChat.ts b/TinyBite/hooks/mutations/useChat.ts new file mode 100644 index 0000000..6fff127 --- /dev/null +++ b/TinyBite/hooks/mutations/useChat.ts @@ -0,0 +1,138 @@ +import { + postApproveJoinParty, + postCompleteParty, + postRejectJoinParty, + postSettleParty, +} from "@/api/partyApi"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import Toast from "react-native-toast-message"; + +export const useApproveJoinPartyMutation = ( + partyId?: number, + participantId?: number, + chatroomId?: number +) => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async () => { + if (!partyId || !participantId) { + throw new Error("필수 파라미터 누락: approve join party"); + } + + return postApproveJoinParty(partyId, participantId); + }, + onSuccess: () => { + // queryClient.invalidateQueries({ + // queryKey: ["getOnetoOneRoomDetail", chatroomId], + // }); + queryClient.invalidateQueries(); + }, + onError: () => { + Toast.show({ + type: "basicToast", + props: { text: "파티 참여 승인에 실패했습니다. 다시 시도해주세요." }, + position: "bottom", + bottomOffset: 133, + visibilityTime: 2000, + }); + }, + }); +}; + +export const useRejectJoinPartyMutation = ( + partyId?: number, + participantId?: number, + chatroomId?: number +) => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async () => { + if (!partyId || !participantId) { + throw new Error("필수 파라미터 누락: approve join party"); + } + + return postRejectJoinParty(partyId, participantId); + }, + onSuccess: () => { + // queryClient.invalidateQueries({ + // queryKey: ["getOnetoOneRoomDetail", chatroomId], + // }); + queryClient.invalidateQueries(); + }, + onError: () => { + Toast.show({ + type: "basicToast", + props: { text: "파티 참여 거절에 실패했습니다. 다시 시도해주세요." }, + position: "bottom", + bottomOffset: 133, + visibilityTime: 2000, + }); + }, + }); +}; + +export const useCompletePartyMutation = ( + partyId?: number, + chatroomId?: number +) => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async () => { + if (!partyId) { + throw new Error("필수 파라미터 누락: approve join party"); + } + + return postCompleteParty(partyId); + }, + onSuccess: () => { + // queryClient.invalidateQueries({ + // queryKey: ["useGetGroupRoomDetailQuery", chatroomId], + // }); + queryClient.invalidateQueries(); + }, + onError: (error) => { + Toast.show({ + type: "basicToast", + props: { text: error.message }, + position: "bottom", + bottomOffset: 133, + visibilityTime: 2000, + }); + }, + }); +}; + +export const useSettlePartyMutation = ( + partyId?: number, + chatroomId?: number +) => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async () => { + if (!partyId) { + throw new Error("필수 파라미터 누락: approve join party"); + } + + return postSettleParty(partyId); + }, + onSuccess: () => { + // queryClient.invalidateQueries({ + // queryKey: ["useGetGroupRoomDetailQuery", chatroomId], + // }); + queryClient.invalidateQueries(); + }, + onError: (error) => { + Toast.show({ + type: "basicToast", + props: { text: error.message }, + position: "bottom", + bottomOffset: 133, + visibilityTime: 2000, + }); + }, + }); +}; diff --git a/TinyBite/hooks/mutations/useParty.ts b/TinyBite/hooks/mutations/useParty.ts index ca5b5ca..146e879 100644 --- a/TinyBite/hooks/mutations/useParty.ts +++ b/TinyBite/hooks/mutations/useParty.ts @@ -15,7 +15,7 @@ export const useRequestJoinParty = () => { onSuccess: (data) => { queryClient.invalidateQueries({ queryKey: ["getOnetoOneRoomList"] }); router.push({ - pathname: "/chat/[id]", + pathname: "/(app)/chat/[id]", params: { id: data, roomType: "ONE_TO_ONE", diff --git a/TinyBite/hooks/queries/useChatRoom.ts b/TinyBite/hooks/queries/useChatRoom.ts index 9c64b45..828c9e5 100644 --- a/TinyBite/hooks/queries/useChatRoom.ts +++ b/TinyBite/hooks/queries/useChatRoom.ts @@ -1,4 +1,9 @@ -import { getOnetoOneRoomList } from "@/api/chatApi"; +import { + getGroupRoomDetail, + getGroupRoomList, + getOnetoOneRoomDetail, + getOnetoOneRoomList, +} from "@/api/chatApi"; import { useQuery } from "@tanstack/react-query"; export const useGetOnetoOneRoomListQuery = () => { @@ -6,5 +11,37 @@ export const useGetOnetoOneRoomListQuery = () => { queryKey: ["getOnetoOneRoomList"], queryFn: getOnetoOneRoomList, staleTime: 0, + refetchOnWindowFocus: true, + }); +}; + +export const useGetOnetoOneRoomDetailQuery = ( + chatroomId: number, + options?: { enabled?: boolean } +) => { + return useQuery({ + queryKey: ["getOnetoOneRoomDetail", chatroomId], + queryFn: () => getOnetoOneRoomDetail(chatroomId), + ...options, + }); +}; + +export const useGetGroupRoomListQuery = () => { + return useQuery({ + queryKey: ["useGetGroupRoomListQuery"], + queryFn: getGroupRoomList, + staleTime: 0, + refetchOnWindowFocus: true, + }); +}; + +export const useGetGroupRoomDetailQuery = ( + chatroomId: number, + options?: { enabled?: boolean } +) => { + return useQuery({ + queryKey: ["useGetGroupRoomDetailQuery", chatroomId], + queryFn: () => getGroupRoomDetail(chatroomId), + ...options, }); }; diff --git a/TinyBite/package-lock.json b/TinyBite/package-lock.json index ff4303f..6939d2c 100644 --- a/TinyBite/package-lock.json +++ b/TinyBite/package-lock.json @@ -1,12 +1,12 @@ { "name": "tinybite", - "version": "0.4.0", - "lockfileVersion": 4, + "version": "0.9.0", + "lockfileVersion": 9, "requires": true, "packages": { "": { "name": "tinybite", - "version": "0.4.0", + "version": "0.9.0", "dependencies": { "@expo/vector-icons": "^15.0.3", "@react-native-google-signin/google-signin": "^16.0.0", diff --git a/TinyBite/package.json b/TinyBite/package.json index d78cce9..853a9af 100644 --- a/TinyBite/package.json +++ b/TinyBite/package.json @@ -1,7 +1,7 @@ { "name": "tinybite", "main": "expo-router/entry", - "version": "0.4.0", + "version": "0.9.0", "scripts": { "start": "expo start", "reset-project": "node ./scripts/reset-project.js", diff --git a/TinyBite/stores/authStore.ts b/TinyBite/stores/authStore.ts index e9ce557..1360f88 100644 --- a/TinyBite/stores/authStore.ts +++ b/TinyBite/stores/authStore.ts @@ -35,6 +35,6 @@ export const useAuthStore = create((set, get) => ({ set({ user: null, isAuthenticated: false }); - router.replace("/login/login"); + router.replace("/(auth)/login/login"); }, })); diff --git a/TinyBite/stores/creatingPartyStore.ts b/TinyBite/stores/creatingPartyStore.ts index 18a1fee..671f48d 100644 --- a/TinyBite/stores/creatingPartyStore.ts +++ b/TinyBite/stores/creatingPartyStore.ts @@ -20,7 +20,7 @@ export interface CreatingPartyState { partyTitle: string; totalAmount: string; numberOfPeople: number; - pickUpLocation: PickupLocation; + pickUpLocation: PickupLocation | null; detailedDescription: string; productLink: string; addPhoto: (uri: string, mimeType: string, fileName: string) => void; @@ -29,7 +29,7 @@ export interface CreatingPartyState { setPartyTitle: (title: string) => void; setTotalAmount: (amount: string) => void; setNumberOfPeople: (number: number) => void; - setPickUpLocation: (location: PickupLocation) => void; + setPickUpLocation: (location: PickupLocation | null) => void; setDetailedDescription: (description: string) => void; setProductLink: (link: string) => void; resetCreateParty: () => void; @@ -42,11 +42,7 @@ export const useCreatingPartyStore = create((set, get) => ({ partyTitle: "", totalAmount: "", numberOfPeople: 2, - pickUpLocation: { - place: "", - pickupLatitude: 0, - pickupLongitude: 0, - }, + pickUpLocation: null, detailedDescription: "", productLink: "", addPhoto: (uri: string, mimeType: string, fileName: string) => { @@ -103,13 +99,15 @@ export const useCreatingPartyStore = create((set, get) => ({ numberOfPeople: number, })); }, - setPickUpLocation: (location: PickupLocation) => { + setPickUpLocation: (location: PickupLocation | null) => { set(() => ({ - pickUpLocation: { - place: location.place, - pickupLatitude: location.pickupLatitude, - pickupLongitude: location.pickupLongitude, - }, + pickUpLocation: location + ? { + place: location.place, + pickupLatitude: location.pickupLatitude, + pickupLongitude: location.pickupLongitude, + } + : null, })); }, setDetailedDescription: (description: string) => { @@ -130,11 +128,7 @@ export const useCreatingPartyStore = create((set, get) => ({ partyTitle: "", totalAmount: "", numberOfPeople: 2, - pickUpLocation: { - place: "중구 명동", - pickupLatitude: 37.55103512680912, - pickupLongitude: 126.9254146711746, - }, + pickUpLocation: null, detailedDescription: "", productLink: "", })); diff --git a/TinyBite/stores/editPartyStore.ts b/TinyBite/stores/editPartyStore.ts index c62d6eb..9dd828d 100644 --- a/TinyBite/stores/editPartyStore.ts +++ b/TinyBite/stores/editPartyStore.ts @@ -8,6 +8,8 @@ export interface PhotoUrl { } export interface editPartyState { + originalInfo: PartyDetail | null; + seq: number; partyId: number; photos: (Photo | PhotoUrl)[]; @@ -26,9 +28,7 @@ export interface editPartyState { }; pickupLocation: { isEdited: boolean; - place: string; - pickupLatitude: number; - pickupLongitude: number; + value: PickupLocation | null; }; description: string; productLink: { @@ -36,20 +36,23 @@ export interface editPartyState { value: string; }; - setInitialPartyInfo: (info: PartyDetail) => void; + setOriginalInfo: (info: PartyDetail) => void; + setInitialPartyInfo: () => void; addPhoto: (uri: string, mimeType: string, fileName: string) => void; deletePhoto: (id: number) => void; setRepresentativePhoto: (id: number) => void; setPartyTitle: (title: string) => void; setTotalAmount: (amount: string) => void; - setPickUpLocation: (location: PickupLocation) => void; + setPickUpLocation: (location: PickupLocation | null) => void; setDetailedDescription: (description: string) => void; setProductLink: (link: string) => void; setMaxParticipants: (number: number) => void; resetEditParty: () => void; } -export const useEditPartyStore = create((set) => ({ +export const useEditPartyStore = create((set, get) => ({ + originalInfo: null, + seq: 0, partyId: 0, photos: [], @@ -68,9 +71,7 @@ export const useEditPartyStore = create((set) => ({ }, pickupLocation: { isEdited: false, - place: "", - pickupLatitude: 0, - pickupLongitude: 0, + value: null, }, description: "", productLink: { @@ -78,7 +79,16 @@ export const useEditPartyStore = create((set) => ({ value: "", }, - setInitialPartyInfo: (info: PartyDetail) => + setOriginalInfo: (info: PartyDetail) => + set({ + originalInfo: info, + }), + setInitialPartyInfo: () => { + const info = get().originalInfo; + if (!info) { + return; + } + set({ seq: info.images?.length || 0, partyId: info.partyId, @@ -101,16 +111,19 @@ export const useEditPartyStore = create((set) => ({ }, pickupLocation: { isEdited: false, - place: info.pickupLocation.place, - pickupLatitude: info.pickupLocation.pickupLatitude, - pickupLongitude: info.pickupLocation.pickupLongitude, + value: { + place: info.pickupLocation.place, + pickupLatitude: info.pickupLocation.pickupLatitude, + pickupLongitude: info.pickupLocation.pickupLongitude, + }, }, description: info.description || "", productLink: { isEdited: false, value: info.productLink?.url || "", }, - }), + }); + }, setPartyTitle: (title: string) => { set(() => ({ title: { @@ -166,16 +179,19 @@ export const useEditPartyStore = create((set) => ({ }, })); }, - setPickUpLocation: (location: PickupLocation) => { + setPickUpLocation: (location: PickupLocation | null) => set(() => ({ - pickupLocation: { - isEdited: true, - place: location.place, - pickupLatitude: location.pickupLatitude, - pickupLongitude: location.pickupLongitude, - }, - })); - }, + pickupLocation: location + ? { + isEdited: true, + value: { + place: location.place, + pickupLatitude: location.pickupLatitude, + pickupLongitude: location.pickupLongitude, + }, + } + : { isEdited: true, value: null }, + })), setDetailedDescription: (description: string) => { set(() => ({ description: description, @@ -199,6 +215,8 @@ export const useEditPartyStore = create((set) => ({ }, resetEditParty: () => set({ + originalInfo: null, + seq: 0, partyId: 0, photos: [], @@ -217,9 +235,7 @@ export const useEditPartyStore = create((set) => ({ }, pickupLocation: { isEdited: false, - place: "", - pickupLatitude: 37.569, - pickupLongitude: 126.991, + value: null, }, description: "", productLink: { diff --git a/TinyBite/types/chat.types.ts b/TinyBite/types/chat.types.ts index ca4dd21..117500a 100644 --- a/TinyBite/types/chat.types.ts +++ b/TinyBite/types/chat.types.ts @@ -1,7 +1,22 @@ +/** + * 필터 탭 타입 정의 + */ +export type FilterTab = "전체" | "참여중인 파티" | "1:1 채팅"; + /** * 채팅방 타입 */ -export type RoomType = "ONE_TO_ONE" | "Group"; +export type RoomType = "ONE_TO_ONE" | "GROUP"; + +/** + * 채팅방 참여자 타입 + */ +export type participantType = "HOST" | "PARTICIPANT"; + +/** + * 파티 카테고리 타입 + */ +export type PartyCategoryType = "delivery" | "grocery" | "essentials"; // ============================================ @@ -47,40 +62,77 @@ export interface OneToOneChatCardSchema { unreadMessageCnt: number; } +/** + * 1:1 채팅방 내부 detail 스키마 + */ +export interface OneToOneChatDetailSchema { + chatRoomId: number; + roomType: "ONE_TO_ONE"; + participantId: number; + participantType: participantType; + participantStatus: OneToOneChatStatusType; + partyId: number; + partyTitle: string; + targetName: string; + + // HOST에게 포함되는 정보 + targetProfileImage?: string; + targetLocation?: string; + + // PARTICIPANT에게 포함되는 정보 + // participantStatus가 APPROVED일 때만 한함 + groupChatRoomId?: number; +} + // ============================================ /** - * 참여중인 파티 상태 태그 타입 + * 그룹 채팅 상태 (백엔드 기준) */ -export type PartyStatusType = "모집 중" | "진행 중" | "파티 종료"; +export type GroupChatStatusType = + | "RECRUITING" + | "COMPLETED" + | "CLOSED" + | "CANCELLED"; /** - * 파티 카테고리 타입 + * 그룹 채팅 상태 : 한글 매핑 객체 (UI 전용) */ -export type PartyCategoryType = "delivery" | "grocery" | "essentials"; +export const GroupChatStatusLabelMap: Record = { + RECRUITING: "모집 중", + COMPLETED: "진행 중", + CLOSED: "파티 종료", + CANCELLED: "파티 취소", +}; /** - * 채팅 아이템 인터페이스 (공통 + 선택적 필드) + * 그룹 채팅 카드 스키마 */ -export type ChatItemType = { - // 1. 공통 필드 (어떤 채팅이든 무조건 있음) - id: string; - name?: string; - lastMessage: string; +export interface GroupChatCardSchema { + chatRoomId: number; roomType: RoomType; - timestamp: string; - unreadCount?: number; - status: OneToOneChatStatusType | PartyStatusType | null; - partyTitle?: string; - - // 2. 1:1 채팅 전용 - opponentProfileImage?: any; //추후에 프로필 이미지 추가 시 url로 수정 - myProfileImage?: any; //추후에 프로필 이미지 추가 시 url로 수정 - - // 3. 파티 채팅 전용 - partyImage?: any; //추후에 파티 이미지 추가 시 url로 수정 - memberCount?: number; - category?: PartyCategoryType; + recentTime: string; // "2026-01-10T23:40:00" + partyTitle: string; + partyImage: string; + partyStatus: GroupChatStatusType; + recentMessage: string; + unreadMessageCnt: number; + currentParticipantCnt: number; +} + +/** + * 채팅방 내부 detail 스키마 + */ +export type GroupChatDetailSchema = { + groupChatRoomId: number; + roomType: "GROUP"; + partyId: number; + partyTitle: string; + participantType: participantType; + status: GroupChatStatusType; + currentParticipantCnt: number; + maxParticipantCnt: number; + formattedPartyCnt: string; }; // ============================================ @@ -131,7 +183,7 @@ export interface TextMessage extends BaseMessage { senderId: number; nickname: string; - isMine: boolean; + // isMine: boolean; text: string; } @@ -146,7 +198,7 @@ export interface ImageUrlMessage extends BaseMessage { senderId: number; nickname: string; - isMine: boolean; + // isMine: boolean; imageUrl: string; } diff --git a/TinyBite/types/party.types.ts b/TinyBite/types/party.types.ts index e1b44e2..41c0f22 100644 --- a/TinyBite/types/party.types.ts +++ b/TinyBite/types/party.types.ts @@ -28,7 +28,7 @@ export type PartyItem = { pricePerPerson: number; participantStatus: string; distance: string; - distanceKm: number; + distanceKm: string; timeAgo: string; isClosed: boolean; category: PartyCategory; @@ -121,6 +121,7 @@ export type PartyDetail = { images: string[]; isClosed: boolean; isParticipating: boolean; + groupChatRoomId?: number; // 참여중일 때만 포함됨 }; /**