diff --git a/TinyBite/api/partyApi.ts b/TinyBite/api/partyApi.ts new file mode 100644 index 0000000..7625b62 --- /dev/null +++ b/TinyBite/api/partyApi.ts @@ -0,0 +1,41 @@ +import { PartyListParams, PartyListResponse } from "@/types/party"; +import { privateAxios } from "./axios"; +import { ENDPOINT } from "./urls"; + +/** + * 파티 리스트 조회 API + * @param params 파티 리스트 조회 파라미터 (category, latitude, longitude) + * @returns 파티 리스트 응답 데이터 + */ +export const getPartyList = async ( + params: PartyListParams +): Promise => { + try { + const res = await privateAxios.get(ENDPOINT.PARTY.GET_PARTIES, { + params: { + category: params.category, + latitude: params.latitude, + longitude: params.longitude, + }, + }); + + const responseData = res.data; + + // hasNext와 totalCount가 없으면 기본값 설정 + return { + activeParties: responseData.activeParties || [], + closedParties: responseData.closedParties || [], + hasNext: responseData.hasNext ?? false, + totalCount: responseData.totalCount ?? 0, + }; + } catch (error) { + console.error("파티 리스트 로딩 실패:", error); + // 에러 발생 시 빈 데이터 구조를 던져주면 "파티가 없어요" 화면을 보여줍니다. + return { + activeParties: [], + closedParties: [], + hasNext: false, + totalCount: 0, + }; + } +}; diff --git a/TinyBite/api/urls.ts b/TinyBite/api/urls.ts index d74f65d..01ad862 100644 --- a/TinyBite/api/urls.ts +++ b/TinyBite/api/urls.ts @@ -17,4 +17,7 @@ export const ENDPOINT = { LOCATION: { FIND_LOCATION: "/api/v1/auth/location", }, + PARTY: { + GET_PARTIES: "/api/parties", + }, }; diff --git a/TinyBite/app/(tabs)/index.tsx b/TinyBite/app/(tabs)/index.tsx index 76c81cf..7d72960 100644 --- a/TinyBite/app/(tabs)/index.tsx +++ b/TinyBite/app/(tabs)/index.tsx @@ -1,16 +1,14 @@ import FloatingMenuButton from "@/components/main/FloatingMenuButton"; import FloatingMenuOverlay from "@/components/main/FloatingMenuOverlay"; -import MainCard from "@/components/main/MainCard"; import MainCategory from "@/components/main/MainCategory"; import MainHeader from "@/components/main/MainHeader"; +import PartyList from "@/components/main/PartyList"; import { colors } from "@/styles/colors"; -import { useRouter } from "expo-router"; import { useState } from "react"; -import { ScrollView, StyleSheet, View } from "react-native"; +import { StyleSheet, View } from "react-native"; export default function HomeScreen() { - const router = useRouter(); const [isMenuOpen, setIsMenuOpen] = useState(false); return ( @@ -21,19 +19,7 @@ export default function HomeScreen() { - - - router.push("/main-card-detail")} /> - - - - - - - + {/* 플로팅 버튼 - 항상 표시 */} @@ -54,19 +40,6 @@ const styles = StyleSheet.create({ flex: 1, backgroundColor: colors.background, }, - scroll: { - backgroundColor: colors.background, - }, - listWrapper: { - alignItems: "center", - marginTop: 5, - marginLeft: 20, - marginRight: 20, - }, - cardWrapper: { - gap: 16, - marginBottom: 16, - }, categoryWrapper: { marginTop: 15, //카테고리 마진 5 뺀 15 marginBottom: 10, //UI 가림 떄문에 리스트에 마진 5+ 카테고리 마진 5 합친 값 뺀 10 diff --git a/TinyBite/assets/images/main/notice-100.png b/TinyBite/assets/images/main/notice-100.png new file mode 100644 index 0000000..822e15d Binary files /dev/null and b/TinyBite/assets/images/main/notice-100.png differ diff --git a/TinyBite/components/main/MainCard.tsx b/TinyBite/components/main/MainCard.tsx index 8be2df0..040eded 100644 --- a/TinyBite/components/main/MainCard.tsx +++ b/TinyBite/components/main/MainCard.tsx @@ -1,5 +1,7 @@ import { colors } from "@/styles/colors"; import { textStyles } from "@/styles/typography/textStyles"; +import { PartyItem } from "@/types/party"; +import { useState } from "react"; import { Image, Pressable, @@ -9,36 +11,107 @@ import { ViewStyle, } from "react-native"; -const MainCard = ({ - onPress, - containerStyle, -}: { +interface MainCardProps { + item: PartyItem; onPress?: () => void; containerStyle?: ViewStyle; -}) => ( - - - - - - 후문 엽떡 나누실 분 ㅃㄹ - - 5,000원 +} + +const MainCard = ({ item, onPress, containerStyle }: MainCardProps) => { + // item이 없으면 렌더링하지 않음 + if (!item) { + return null; + } + + // 이미지 로딩 에러 상태 관리 + const [imageError, setImageError] = useState(false); + + // 가격 포맷팅 (예: 5000 -> "5,000원") + const formattedPrice = `${item.pricePerPerson.toLocaleString()}원`; + + // 카테고리별 아이콘 매핑 + const getCategoryIcon = (category: string) => { + switch (category) { + case "ALL": + return require("@/assets/images/main/notice-100.png"); + case "DELIVERY": + return require("@/assets/images/main/category/delivery.png"); + case "GROCERY": + return require("@/assets/images/main/category/grocery.png"); + case "HOUSEHOLD": + return require("@/assets/images/main/category/essentials.png"); + default: + return null; + } + }; + + const hasImage = + item.thumbnailImage && item.thumbnailImage.trim() !== "" && !imageError; + const categoryIcon = getCategoryIcon(item.category); + + return ( + + + {hasImage ? ( + <> + { + console.warn("이미지 로딩 실패:", item.thumbnailImage); + setImageError(true); + }} + /> + {item.isClosed && } + + ) : ( + + {categoryIcon && ( + + )} + {item.isClosed && } + + )} - - - 1/4명 + + + + {item.title} + + + {formattedPrice} + + + + + + {item.isClosed ? "마감" : item.participantStatus} + + + + {item.distance} | {item.timeAgo} + - - 10KM 이내 | 10분 전 - - - -); + + ); +}; export default MainCard; @@ -60,10 +133,38 @@ const styles = StyleSheet.create({ shadowRadius: 2, elevation: 4, }, - thumbnail: { + thumbnailContainer: { width: 90, height: 90, borderRadius: 16, + position: "relative", + overflow: "hidden", + }, + thumbnail: { + width: "100%", + height: "100%", + borderRadius: 16, + }, + thumbnailPlaceholder: { + width: "100%", + height: "100%", + borderRadius: 16, + backgroundColor: colors.sub, + justifyContent: "center", + alignItems: "center", + }, + categoryIcon: { + width: 60, + height: 60, + }, + overlay: { + position: "absolute", + top: 0, + left: 0, + right: 0, + bottom: 0, + backgroundColor: "rgba(0, 0, 0, 0.4)", + borderRadius: 16, }, cardBody: { flex: 1, @@ -84,18 +185,21 @@ const styles = StyleSheet.create({ }, badge: { backgroundColor: colors.main, - padding: 0.5, - width: 51, - height: 26, borderRadius: 100, paddingHorizontal: 10, paddingVertical: 4, justifyContent: "center", alignItems: "center", }, + badgeClosed: { + backgroundColor: colors.gray[2], + }, badgeText: { color: colors.white, }, + badgeTextClosed: { + color: colors.white, + }, meta: { color: colors.gray[1], }, diff --git a/TinyBite/components/main/MainCategory.tsx b/TinyBite/components/main/MainCategory.tsx index c096bbf..7ae9a02 100644 --- a/TinyBite/components/main/MainCategory.tsx +++ b/TinyBite/components/main/MainCategory.tsx @@ -1,58 +1,81 @@ +import { usePartyStore } from "@/stores/partyStore"; import { colors } from "@/styles/colors"; import { textStyles } from "@/styles/typography/textStyles"; -import { Image, ScrollView, StyleSheet, Text, View } from "react-native"; +import { PartyCategory } from "@/types/party"; +import { + Image, + ScrollView, + StyleSheet, + Text, + TouchableOpacity, +} from "react-native"; const PRIMARY_COLOR = colors.main; const ACTIVE_BG = colors.sub; const INACTIVE_BG = colors.white; const GRAY_TEXT = colors.gray[1]; -const items = [ - { label: "전체", icon: null, active: true }, +const items: { label: string; value: PartyCategory; icon: any }[] = [ + { label: "전체", value: "ALL", icon: null }, { label: "배달", + value: "DELIVERY", icon: require("@/assets/images/main/category/delivery.png"), - active: false, }, { label: "장보기", + value: "GROCERY", icon: require("@/assets/images/main/category/grocery.png"), - active: false, }, { label: "생필품", + value: "HOUSEHOLD", icon: require("@/assets/images/main/category/essentials.png"), - active: false, }, ]; -const MainCategory = () => ( - - {items.map(({ label, icon, active }) => ( - - {icon ? ( - - ) : null} - { + const partyType = usePartyStore((state) => state.partyType); + const setPartyType = usePartyStore((state) => state.setPartyType); + + return ( + + {items.map(({ label, value, icon }) => ( + setPartyType(value)} > - {label} - - - ))} - -); + {icon ? ( + + ) : null} + + {label} + + + ))} + + ); +}; export default MainCategory; @@ -86,6 +109,8 @@ const styles = StyleSheet.create({ }, chipInactive: { backgroundColor: INACTIVE_BG, + borderColor: "transparent", // 투명 보더로 크기 유지 + borderWidth: 1, // 활성 상태와 동일한 보더 두께 }, text: { textAlign: "center", diff --git a/TinyBite/components/main/MainHeader.tsx b/TinyBite/components/main/MainHeader.tsx index cee91cc..cab1eb8 100644 --- a/TinyBite/components/main/MainHeader.tsx +++ b/TinyBite/components/main/MainHeader.tsx @@ -1,3 +1,4 @@ +import { useAuthStore } from "@/stores/authStore"; import { colors } from "@/styles/colors"; import { textStyles } from "@/styles/typography/textStyles"; import { useState } from "react"; @@ -9,37 +10,39 @@ const PRIMARY_COLOR = colors.main; interface MainHeaderProps {} -/** - * 메인 헤더에 표시될 캐러셀 데이터 - * 각 아이템은 닉네임, 인사말, 캐릭터 이미지, 배경색 등을 포함합니다. - */ -const carouselData: CarouselItem[] = [ - { - greeting1: "가짜대학생", - greeting2: "님,\n오늘은 무엇을 나눌까요 ?", - character: require("@/assets/images/main/character.png"), - backgroundColor: PRIMARY_COLOR, - greeting1Style: textStyles.title20_B135, - greeting2Style: textStyles.title18_B135, - }, - { - greeting1: "저희 앱 어때요?\n의견이 필요해요", - greeting2: "\n츄비 눌러서 의견 주기 >", - character: require("@/assets/images/main/character-opinion.png"), - backgroundColor: PRIMARY_COLOR, - greeting1Style: textStyles.title24_SB135, - greeting2Style: [textStyles.title18_SB135, { marginTop: -20 }], - }, -]; - /** * 메인 화면 상단 헤더 컴포넌트 * - 로고와 현재 위치 정보를 표시 * - 캐러셀을 통해 여러 인사말과 캐릭터 이미지를 순환 표시 */ const MainHeader = ({}: MainHeaderProps = {}) => { + // 스토어에서 사용자 정보 가져오기 + const nickname = useAuthStore((state) => state.user?.nickname || "한입만"); + const location = useAuthStore((state) => state.user?.location || "위치 없음"); + // 현재 캐러셀 페이지 인덱스 상태 관리 const [currentPage, setCurrentPage] = useState(0); + + // 메인 헤더에 표시될 캐러셀 데이터 (닉네임을 동적으로 설정) + const carouselData: CarouselItem[] = [ + { + greeting1: nickname, + greeting2: "님,\n오늘은 무엇을 나눌까요 ?", + character: require("@/assets/images/main/character.png"), + backgroundColor: PRIMARY_COLOR, + greeting1Style: textStyles.title20_B135, + greeting2Style: textStyles.title18_B135, + }, + { + greeting1: "저희 앱 어때요?\n의견이 필요해요", + greeting2: "\n츄비 눌러서 의견 주기 >", + character: require("@/assets/images/main/character-opinion.png"), + backgroundColor: PRIMARY_COLOR, + greeting1Style: textStyles.title24_SB135, + greeting2Style: [textStyles.title18_SB135, { marginTop: -20 }], + }, + ]; + // 현재 페이지에 해당하는 데이터 가져오기 const currentData = carouselData[currentPage]; @@ -64,7 +67,9 @@ const MainHeader = ({}: MainHeaderProps = {}) => { style={styles.mainLogo} resizeMode="contain" /> - 역삼동 + + {location} + {/* 인사말과 캐릭터 이미지 캐러셀 (스와이프 가능) */} diff --git a/TinyBite/components/main/PartyList.tsx b/TinyBite/components/main/PartyList.tsx new file mode 100644 index 0000000..f9a0a5e --- /dev/null +++ b/TinyBite/components/main/PartyList.tsx @@ -0,0 +1,119 @@ +import MainCard from "@/components/main/MainCard"; +import { usePartyList } from "@/hooks/usePartyList"; +import { useUserCoords } from "@/hooks/useUserCoords"; +import { colors } from "@/styles/colors"; +import { textStyles } from "@/styles/typography/textStyles"; +import { PartyItem } from "@/types/party"; +import { useRouter } from "expo-router"; +import { useEffect } from "react"; +import { FlatList, Image, StyleSheet, Text, View } from "react-native"; + +/** + * 파티 리스트 컴포넌트 + * - 내부에서 위치 정보와 파티 리스트 데이터를 직접 조회 + * - 활성 파티를 먼저 표시하고, 그 다음 종료된 파티를 표시 + * - 카드 클릭 시 상세 페이지로 이동 + */ +const PartyList = () => { + const router = useRouter(); + // 위치 정보 가져오기 + const { coords, refresh: fetchCoords } = useUserCoords(); + + // 컴포넌트 마운트 시 위치 정보 가져오기 + useEffect(() => { + if (!coords) { + fetchCoords(); + } + }, []); + + // 파티 리스트 조회 (내부에서 직접 호출) + const { data, isLoading } = usePartyList({ + latitude: coords?.latitude?.toString() || "", + longitude: coords?.longitude?.toString() || "", + }); + + const activeParties = data?.activeParties || []; + const closedParties = data?.closedParties || []; + + // 활성 파티와 종료된 파티를 합쳐서 하나의 배열로 만들기 + const allParties: PartyItem[] = [...activeParties, ...closedParties]; + + const renderItem = ({ item }: { item: PartyItem }) => ( + router.push("/main-card-detail")} + //TODO: 상세 페이지로 이동 시 파티 ID 전달 + containerStyle={styles.cardItem} + /> + ); + + const ItemSeparator = () => ; + + const ListEmptyComponent = () => { + // 로딩 중일 때는 빈 상태를 표시하지 않음 + if (isLoading) { + return null; + } + + return ( + + + + 아직 우리 동네에 파티가 없어요. + + + 첫 파티를 열어보세요! + + + ); + }; + + return ( + item.partyId.toString()} + ItemSeparatorComponent={ItemSeparator} + ListEmptyComponent={ListEmptyComponent} + contentContainerStyle={styles.listContent} + showsVerticalScrollIndicator={false} + /> + ); +}; + +export default PartyList; + +const styles = StyleSheet.create({ + listContent: { + alignItems: "center", + marginTop: 5, + marginLeft: 20, + marginRight: 20, + paddingBottom: 70, // 플로팅 버튼 공간 확보 + flexGrow: 1, // 빈 상태일 때 전체 화면 사용 + }, + cardItem: { + width: "100%", + }, + separator: { + height: 16, + }, + emptyContainer: { + flex: 1, + justifyContent: "center", + alignItems: "center", + }, + emptyIcon: { + width: 100, + height: 100, + marginBottom: 12, + }, + emptyText: { + color: colors.gray[1], + textAlign: "center", + }, +}); diff --git a/TinyBite/hooks/usePartyList.ts b/TinyBite/hooks/usePartyList.ts new file mode 100644 index 0000000..3ed410a --- /dev/null +++ b/TinyBite/hooks/usePartyList.ts @@ -0,0 +1,24 @@ +import { getPartyList } from "@/api/partyApi"; +import { usePartyStore } from "@/stores/partyStore"; +import { PartyListResponse } from "@/types/party"; +import { useQuery } from "@tanstack/react-query"; + +/** + * 파티 리스트 조회 Hook + * @param location 사용자 위치 정보 (latitude, longitude) + * @returns React Query 결과 (data, isLoading, error 등) + */ +export const usePartyList = (location: { + latitude: string; + longitude: string; +}) => { + const partyType = usePartyStore((state) => state.partyType); + + const { data, isLoading, error } = useQuery({ + queryKey: ["getParties", partyType, location.latitude, location.longitude], + queryFn: () => getPartyList({ category: partyType, ...location }), + enabled: !!location.latitude && !!location.longitude, // latitude와 longitude가 있을 때만 실행 + }); + + return { data, isLoading, error }; +}; diff --git a/TinyBite/stores/partyStore.ts b/TinyBite/stores/partyStore.ts new file mode 100644 index 0000000..313c117 --- /dev/null +++ b/TinyBite/stores/partyStore.ts @@ -0,0 +1,12 @@ +import { PartyCategory } from "@/types/party"; +import { create } from "zustand"; + +export interface PartyState { + partyType: PartyCategory; + setPartyType: (category: PartyCategory) => void; +} + +export const usePartyStore = create((set) => ({ + partyType: "ALL", + setPartyType: (category: PartyCategory) => set({ partyType: category }), +})); diff --git a/TinyBite/types/party.ts b/TinyBite/types/party.ts new file mode 100644 index 0000000..46d8418 --- /dev/null +++ b/TinyBite/types/party.ts @@ -0,0 +1,40 @@ +/** + * 파티 카테고리 타입 + */ +export type PartyCategory = "ALL" | "DELIVERY" | "GROCERY" | "HOUSEHOLD"; + +/** + * 파티 리스트 조회 요청 파라미터 타입 + */ +export type PartyListParams = { + category?: PartyCategory; + latitude: string; + longitude: string; +}; + +/** + * 파티 아이템 타입 + */ +export type PartyItem = { + partyId: number; + thumbnailImage: string; + title: string; + pricePerPerson: number; + participantStatus: string; + distance: string; + distanceKm: number; + timeAgo: string; + isClosed: boolean; + category: PartyCategory; + createdAt: string; +}; + +/** + * 파티 리스트 API 응답 타입 + */ +export type PartyListResponse = { + activeParties: PartyItem[]; + closedParties: PartyItem[]; + hasNext: boolean; + totalCount: number; +};