diff --git a/app/(tabs)/myPage/notice.tsx b/app/(tabs)/myPage/notice.tsx index f246bbc..2659615 100644 --- a/app/(tabs)/myPage/notice.tsx +++ b/app/(tabs)/myPage/notice.tsx @@ -1,22 +1,163 @@ -import { styled } from "styled-components/native"; +import { useCallback, useEffect, useState } from "react"; + +import { ActivityIndicator, FlatList } from "react-native"; + +import { useRouter } from "expo-router"; +import { ChevronLeft, ChevronRight } from "lucide-react-native"; +import styled from "styled-components/native"; + +import { NoticePageItem } from "@/types/notice"; + +import { theme } from "@/styles/theme"; + +import { getNoticeList } from "@/api/notice/getNoticeApi"; export default function Notice() { - return ( - - 공지사항 - - ); + const router = useRouter(); + const [notices, setNotices] = useState([]); + const [page, setPage] = useState(0); + const [hasNext, setHasNext] = useState(false); + const [isLoading, setIsLoading] = useState(false); + + const fetchNotices = async (reset: boolean = false) => { + if (isLoading) return; + setIsLoading(true); + try { + const currentPage = reset ? 0 : page; + const data = await getNoticeList(currentPage); + setNotices(reset ? data.content : [...notices, ...data.content]); + setHasNext(data.hasNext); + setPage(currentPage + 1); + } catch (error) { + console.error("공지사항 목록 조회 실패:", error); + } finally { + setIsLoading(false); + } + }; + + useEffect(() => { + fetchNotices(true); + }, []); + + const handleItemPress = (noticeId: number) => { + router.push({ + pathname: "/myPage/noticeDetail", + params: { noticeId }, + }); + }; + + const renderItem = useCallback( + ({ item }: { item: NoticePageItem }) => ( + handleItemPress(item.noticeId)}> + + {item.title} + {item.createdAt.replace(/\//g, ".")} + + + + ), + [] + ); + + return ( + +
+ router.back()}> + + + 공지사항 + +
+ + item.noticeId.toString()} + renderItem={renderItem} + onEndReached={() => { + if (hasNext && !isLoading) fetchNotices(false); + }} + onEndReachedThreshold={0.5} + ListFooterComponent={ + isLoading && notices.length > 0 ? ( + + ) : null + } + ListEmptyComponent={ + !isLoading ? ( + + 공지사항이 없습니다 + + ) : null + } + contentContainerStyle={{ flexGrow: 1 }} + /> +
+ ); } const Container = styled.View` - font-family: ${({ theme }) => theme.typography.fontFamily.regular}; flex: 1; - justify-content: "center"; - align-items: "center"; + background-color: ${theme.colors.white}; +`; + +const Header = styled.View` + height: 50px; + flex-direction: row; + align-items: center; + justify-content: space-between; + padding-horizontal: 16px; + border-bottom-width: 1px; + border-bottom-color: ${theme.colors.grey.neutral200}; +`; + +const BackButton = styled.TouchableOpacity` + width: 32px; `; -const Title = styled.Text` - font-size: ${({ theme }) => theme.typography.fontSize.xxxl}; - color: ${({ theme }) => theme.colors.text.textPrimary}; - font-family: ${({ theme }) => theme.typography.fontFamily.regular}; +const HeaderTitle = styled.Text` + font-family: ${theme.typography.fontFamily.semiBold}; + font-size: ${theme.typography.fontSize.lg}px; + color: ${theme.colors.text.textPrimary}; `; + +const Spacer = styled.View` + width: 32px; +`; + +const NoticeItem = styled.TouchableOpacity` + flex-direction: row; + justify-content: space-between; + align-items: center; + padding: 16px 20px; + border-bottom-width: 1px; + border-bottom-color: ${theme.colors.grey.neutral200}; +`; + +const NoticeInfo = styled.View` + flex: 1; +`; + +const NoticeTitle = styled.Text` + font-family: ${theme.typography.fontFamily.medium}; + font-size: ${theme.typography.fontSize.sm}px; + color: ${theme.colors.text.textPrimary}; +`; + +const NoticeDate = styled.Text` + font-family: ${theme.typography.fontFamily.regular}; + font-size: ${theme.typography.fontSize.xxs}px; + color: ${theme.colors.text.textTertiary}; + margin-top: 4px; +`; + +const EmptyContainer = styled.View` + flex: 1; + justify-content: center; + align-items: center; +`; + +const EmptyText = styled.Text` + font-family: ${theme.typography.fontFamily.regular}; + font-size: ${theme.typography.fontSize.sm}px; + color: ${theme.colors.text.textTertiary}; +`; \ No newline at end of file diff --git a/app/(tabs)/myPage/noticeDetail.tsx b/app/(tabs)/myPage/noticeDetail.tsx new file mode 100644 index 0000000..2804f3e --- /dev/null +++ b/app/(tabs)/myPage/noticeDetail.tsx @@ -0,0 +1,144 @@ +import { useEffect, useState } from "react"; + +import { ActivityIndicator, ScrollView } from "react-native"; + +import { useLocalSearchParams, useRouter } from "expo-router"; +import { ChevronLeft } from "lucide-react-native"; +import styled from "styled-components/native"; + +import { NoticeDetail as NoticeDetailType } from "@/types/notice"; + +import { theme } from "@/styles/theme"; + +import { getNoticeDetail } from "@/api/notice/getNoticeApi"; + +export default function NoticeDetail() { + const router = useRouter(); + const { noticeId } = useLocalSearchParams<{ noticeId: string }>(); + const [notice, setNotice] = useState(null); + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + if (!noticeId) return; + const fetch = async () => { + try { + const data = await getNoticeDetail(Number(noticeId)); + setNotice(data); + } catch (error) { + console.error("공지사항 상세 조회 실패:", error); + } finally { + setIsLoading(false); + } + }; + fetch(); + }, [noticeId]); + + if (isLoading) { + return ( + +
+ router.back()}> + + +
+ + + +
+ ); + } + + if (!notice) { + return ( + +
+ router.back()}> + + +
+ + 공지사항을 찾을 수 없습니다 + +
+ ); + } + + return ( + +
+ router.back()}> + + +
+ + + {notice.createdAt.replace(/\//g, ".")} + {notice.noticeTitle} + + {notice.noticeContent} + +
+ ); +} + +const Container = styled.View` + flex: 1; + background-color: ${theme.colors.white}; +`; + +const Header = styled.View` + height: 50px; + flex-direction: row; + align-items: center; + padding-horizontal: 16px; +`; + +const BackButton = styled.TouchableOpacity` + width: 32px; +`; + +const LoadingContainer = styled.View` + flex: 1; + justify-content: center; + align-items: center; +`; + +const DateText = styled.Text` + font-family: ${theme.typography.fontFamily.regular}; + font-size: ${theme.typography.fontSize.xxs}px; + color: ${theme.colors.text.textTertiary}; + margin-bottom: 8px; +`; + +const TitleText = styled.Text` + font-family: ${theme.typography.fontFamily.bold}; + font-size: ${theme.typography.fontSize.lg}px; + color: ${theme.colors.text.textPrimary}; + margin-bottom: 16px; + line-height: ${theme.typography.fontSize.lg * 1.4}px; +`; + +const Divider = styled.View` + height: 1px; + background-color: ${theme.colors.grey.neutral200}; + margin-bottom: 16px; +`; + +const ContentText = styled.Text` + font-family: ${theme.typography.fontFamily.regular}; + font-size: ${theme.typography.fontSize.xs}px; + color: ${theme.colors.text.textPrimary}; + line-height: ${theme.typography.fontSize.xs * 1.6}px; +`; + +const EmptyContainer = styled.View` + flex: 1; + justify-content: center; + align-items: center; +`; + +const EmptyText = styled.Text` + font-family: ${theme.typography.fontFamily.regular}; + font-size: ${theme.typography.fontSize.sm}px; + color: ${theme.colors.text.textTertiary}; +`; \ No newline at end of file diff --git a/src/api/notice/getNoticeApi.ts b/src/api/notice/getNoticeApi.ts new file mode 100644 index 0000000..b9852d4 --- /dev/null +++ b/src/api/notice/getNoticeApi.ts @@ -0,0 +1,34 @@ +import { BaseResponse } from "@/types/auth"; +import { NoticeDetail, NoticePageResponse } from "@/types/notice"; + +import API_ENDPOINTS from "@/constants/endpoints"; + +import api from "../axios"; + +export const getNoticeList = async ( + page: number = 0, + size: number = 10 +): Promise => { + try { + const response = await api.get>( + API_ENDPOINTS.NOTICE.LIST, + { params: { page, size, sort: "id,desc" } } + ); + return response.data.data; + } catch (error) { + throw error; + } +}; + +export const getNoticeDetail = async ( + noticeId: number +): Promise => { + try { + const response = await api.get>( + API_ENDPOINTS.NOTICE.DETAIL(noticeId) + ); + return response.data.data; + } catch (error) { + throw error; + } +}; \ No newline at end of file diff --git a/src/constants/endpoints.ts b/src/constants/endpoints.ts index dce7864..cfd569e 100644 --- a/src/constants/endpoints.ts +++ b/src/constants/endpoints.ts @@ -58,6 +58,11 @@ export const API_ENDPOINTS = { EDIT_PROFILE: "/api/user/member/profile", CHANGE_PASSWORD: "/api/user/member/password", }, + + NOTICE: { + LIST: "/api/core/notice", + DETAIL: (noticeId: number) => `/api/core/notice/${noticeId}`, + }, }; export default API_ENDPOINTS; diff --git a/src/types/notice.ts b/src/types/notice.ts new file mode 100644 index 0000000..e1321be --- /dev/null +++ b/src/types/notice.ts @@ -0,0 +1,23 @@ +export interface NoticePageItem { + noticeId: number; + title: string; + createdAt: string; + updatedAt: string; +} +export interface NoticeDetail { + noticeId: number; + noticeTitle: string; + noticeContent: string; + createdAt: string; + updatedAt: string; +} + +export interface NoticePageResponse { + content: NoticePageItem[]; + number: number; + size: number; + isFirst: boolean; + isLast: boolean; + hasNext: boolean; + hasPrevious: boolean; +} \ No newline at end of file