Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
167 changes: 154 additions & 13 deletions app/(tabs)/myPage/notice.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Container>
<Title>공지사항</Title>
</Container>
);
const router = useRouter();
const [notices, setNotices] = useState<NoticePageItem[]>([]);
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 }) => (
<NoticeItem onPress={() => handleItemPress(item.noticeId)}>
<NoticeInfo>
<NoticeTitle>{item.title}</NoticeTitle>
<NoticeDate>{item.createdAt.replace(/\//g, ".")}</NoticeDate>
</NoticeInfo>
<ChevronRight size={20} color={theme.colors.grey.neutral400} />
</NoticeItem>
),
[]
);

return (
<Container>
<Header>
<BackButton onPress={() => router.back()}>
<ChevronLeft size={24} color={theme.colors.text.textPrimary} />
</BackButton>
<HeaderTitle>공지사항</HeaderTitle>
<Spacer />
</Header>

<FlatList
data={notices}
keyExtractor={(item) => item.noticeId.toString()}
renderItem={renderItem}
onEndReached={() => {
if (hasNext && !isLoading) fetchNotices(false);
}}
onEndReachedThreshold={0.5}
ListFooterComponent={
isLoading && notices.length > 0 ? (
<ActivityIndicator style={{ padding: 20 }} />
) : null
}
ListEmptyComponent={
!isLoading ? (
<EmptyContainer>
<EmptyText>공지사항이 없습니다</EmptyText>
</EmptyContainer>
) : null
}
contentContainerStyle={{ flexGrow: 1 }}
/>
</Container>
);
}

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};
`;
144 changes: 144 additions & 0 deletions app/(tabs)/myPage/noticeDetail.tsx
Original file line number Diff line number Diff line change
@@ -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<NoticeDetailType | null>(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 (
<Container>
<Header>
<BackButton onPress={() => router.back()}>
<ChevronLeft size={24} color={theme.colors.text.textPrimary} />
</BackButton>
</Header>
<LoadingContainer>
<ActivityIndicator />
</LoadingContainer>
</Container>
);
}

if (!notice) {
return (
<Container>
<Header>
<BackButton onPress={() => router.back()}>
<ChevronLeft size={24} color={theme.colors.text.textPrimary} />
</BackButton>
</Header>
<EmptyContainer>
<EmptyText>공지사항을 찾을 수 없습니다</EmptyText>
</EmptyContainer>
</Container>
);
}

return (
<Container>
<Header>
<BackButton onPress={() => router.back()}>
<ChevronLeft size={24} color={theme.colors.text.textPrimary} />
</BackButton>
</Header>

<ScrollView contentContainerStyle={{ padding: 20 }}>
<DateText>{notice.createdAt.replace(/\//g, ".")}</DateText>
<TitleText>{notice.noticeTitle}</TitleText>
<Divider />
<ContentText>{notice.noticeContent}</ContentText>
</ScrollView>
</Container>
);
}

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};
`;
34 changes: 34 additions & 0 deletions src/api/notice/getNoticeApi.ts
Original file line number Diff line number Diff line change
@@ -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<NoticePageResponse> => {
try {
const response = await api.get<BaseResponse<NoticePageResponse>>(
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<NoticeDetail> => {
try {
const response = await api.get<BaseResponse<NoticeDetail>>(
API_ENDPOINTS.NOTICE.DETAIL(noticeId)
);
return response.data.data;
} catch (error) {
throw error;
}
};
5 changes: 5 additions & 0 deletions src/constants/endpoints.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
23 changes: 23 additions & 0 deletions src/types/notice.ts
Original file line number Diff line number Diff line change
@@ -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;
}