diff --git a/app/(application)/(user)/profile/[id]/blocked-list.tsx b/app/(application)/(user)/profile/[id]/blocked-list.tsx new file mode 100644 index 0000000..2582ebd --- /dev/null +++ b/app/(application)/(user)/profile/[id]/blocked-list.tsx @@ -0,0 +1,148 @@ +import { + AvatarUsernameAndOtherComponent, + BadgeCustom, + ClickableCustom, + Divider, + SelectCustom, + TextCustom, +} from "@/components"; +import ListEmptyComponent from "@/components/_ShareComponent/ListEmptyComponent"; +import ListLoaderFooterComponent from "@/components/_ShareComponent/ListLoaderFooterComponent"; +import ListSkeletonComponent from "@/components/_ShareComponent/ListSkeletonComponent"; +import NewWrapper from "@/components/_ShareComponent/NewWrapper"; +import { MainColor } from "@/constants/color-palet"; +import { useAuth } from "@/hooks/use-auth"; +import { usePaginatedApi } from "@/hooks/use-paginated-api"; +import { apiGetBlocked } from "@/service/api-client/api-blocked"; +import { apiMasterAppCategory } from "@/service/api-client/api-master"; +import { router, useFocusEffect } from "expo-router"; +import _ from "lodash"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { RefreshControl, View } from "react-native"; + +const PAGE_SIZE = 10; +export default function ProfileBlockedList() { + const { user } = useAuth(); + const [masterApp, setMasterApp] = useState([]); + const isInitialMount = useRef(true); + + const { + data: listData, + loading, + refreshing, + hasMore, + search, + setSearch, + onRefresh, + loadMore, + } = usePaginatedApi({ + fetcher: async (params: { page: number; search?: string }) => { + const response = await apiGetBlocked({ + id: user?.id as any, + search: search, + page: String(params.page) as any, + }); + + return response.data; + }, + initialSearch: "", + pageSize: PAGE_SIZE, + dependencies: [user?.id], + }); + + useEffect(() => { + fetchMasterApp(); + }, []); + + // 🔁 Refresh otomatis saat kembali ke halaman ini + useFocusEffect( + useCallback(() => { + if (isInitialMount.current) { + // Skip saat pertama kali mount + isInitialMount.current = false; + return; + } + // Hanya refresh saat kembali dari screen lain + onRefresh(); + }, [onRefresh]) + ); + + const fetchMasterApp = async () => { + const response = await apiMasterAppCategory(); + setMasterApp(response.data); + }; + + const renderHeader = () => ( + ({ + label: item.name, + value: item.id, + }))} + value={search === "" ? undefined : search} + onChange={(value) => { + setSearch(value as any); + }} + /> + ); + + const renderItem = ({ item }: { item: any }) => ( + <> + { + router.push(`/profile/${item.id}/detail-blocked`); + }} + > + + + + + {item?.menuFeature?.name} + + + + } + /> + + + + + ); + + return ( + <> + + } + ListFooterComponent={ + hasMore && !refreshing ? : null + } + ListEmptyComponent={ + !loading && _.isEmpty(listData) ? ( + + ) : ( + + ) + } + /> + + ); +} diff --git a/app/(application)/(user)/profile/[id]/detail-blocked.tsx b/app/(application)/(user)/profile/[id]/detail-blocked.tsx new file mode 100644 index 0000000..920cb25 --- /dev/null +++ b/app/(application)/(user)/profile/[id]/detail-blocked.tsx @@ -0,0 +1,93 @@ +import { + AlertDefaultSystem, + AvatarUsernameAndOtherComponent, + BaseBox, + BoxButtonOnFooter, + BoxWithHeaderSection, + ButtonCustom, + NewWrapper, + StackCustom, + TextCustom, +} from "@/components"; +import AvatarAndBackground from "@/screens/Profile/AvatarAndBackground"; +import { + apiGetBlockedById, + apiUnblock, +} from "@/service/api-client/api-blocked"; +import { router, useLocalSearchParams } from "expo-router"; +import _ from "lodash"; +import { useEffect, useState } from "react"; + +export default function ProfileDetailBlocked() { + const { id } = useLocalSearchParams(); + const [data, setData] = useState(null); + const [isLoading, setIsLoading] = useState(false); + + useEffect(() => { + fetchData(); + }, [id]); + + const fetchData = async () => { + const response = await apiGetBlockedById({ id: String(id) }); + // console.log("[RESPONSE >>]", JSON.stringify(response, null, 2)); + setData(response.data); + }; + + const handleSubmit = async () => { + try { + setIsLoading(true); + await apiUnblock({ id: String(id) }); + router.back(); + } catch (error) { + console.log("[ERROR >>]", JSON.stringify(error, null, 2)); + } finally { + setIsLoading(false); + } + }; + + return ( + <> + + { + AlertDefaultSystem({ + title: "Buka Blokir", + message: "Apakah anda yakin ingin membuka blokir ini?", + textLeft: "Tidak", + textRight: "Ya", + onPressRight: () => { + handleSubmit(); + }, + }); + }} + > + Buka Blokir + + + } + > + + + + + + Jika anda membuka blokir ini maka semua postingan terkait user ini + akan muncul kembali di beranda + + {" "} + {_.upperCase(data?.menuFeature?.name)} + + + + + + + ); +} diff --git a/app/(application)/(user)/profile/_layout.tsx b/app/(application)/(user)/profile/_layout.tsx index 5e5b856..d9e8701 100644 --- a/app/(application)/(user)/profile/_layout.tsx +++ b/app/(application)/(user)/profile/_layout.tsx @@ -33,6 +33,16 @@ export default function ProfileLayout() { name="create" options={{ title: "Buat Profile", headerBackVisible: false }} /> + + }} + /> + + }} + /> ); diff --git a/components/_ShareComponent/ListEmptyComponent.tsx b/components/_ShareComponent/ListEmptyComponent.tsx new file mode 100644 index 0000000..5e35bc1 --- /dev/null +++ b/components/_ShareComponent/ListEmptyComponent.tsx @@ -0,0 +1,20 @@ +import { View } from "react-native"; +import TextCustom from "../Text/TextCustom"; + +// Komponen Empty +const ListEmptyComponent = ({ search }: { search?: string }) => ( + + + {search ? "Tidak ada hasil pencarian" : "Tidak ada data"} + + +); + +export default ListEmptyComponent; diff --git a/components/_ShareComponent/ListLoaderFooterComponent.tsx b/components/_ShareComponent/ListLoaderFooterComponent.tsx new file mode 100644 index 0000000..dbc2619 --- /dev/null +++ b/components/_ShareComponent/ListLoaderFooterComponent.tsx @@ -0,0 +1,11 @@ +import { View } from "react-native"; +import LoaderCustom from "../Loader/LoaderCustom"; + +const ListLoaderFooterComponent = () =>( + + + +) + + +export default ListLoaderFooterComponent; diff --git a/components/_ShareComponent/ListSkeletonComponent.tsx b/components/_ShareComponent/ListSkeletonComponent.tsx new file mode 100644 index 0000000..c7adf93 --- /dev/null +++ b/components/_ShareComponent/ListSkeletonComponent.tsx @@ -0,0 +1,21 @@ +import { View } from "react-native"; +import StackCustom from "../Stack/StackCustom"; +import SkeletonCustom from "./SkeletonCustom"; + +const ListSkeletonComponent = ({ + length = 5, + height = 100, +}: { + length?: number; + height?: number; +}) => ( + + + {Array.from({ length }).map((_, i) => ( + + ))} + + +); + +export default ListSkeletonComponent; diff --git a/components/_ShareComponent/NewWrapper.tsx b/components/_ShareComponent/NewWrapper.tsx index 9f0586d..c59245e 100644 --- a/components/_ShareComponent/NewWrapper.tsx +++ b/components/_ShareComponent/NewWrapper.tsx @@ -40,8 +40,8 @@ interface StaticModeProps extends BaseProps { interface ListModeProps extends BaseProps { children?: never; - listData: any[]; - renderItem: FlatListProps["renderItem"]; + listData?: any[]; + renderItem?: FlatListProps["renderItem"]; onEndReached?: () => void; // ✅ Gunakan tipe yang kompatibel dengan FlatList ListHeaderComponent?: React.ReactElement | null; diff --git a/components/index.ts b/components/index.ts index 9c05846..f53c40f 100644 --- a/components/index.ts +++ b/components/index.ts @@ -59,6 +59,7 @@ import ViewWrapper from "./_ShareComponent/ViewWrapper"; import SearchInput from "./_ShareComponent/SearchInput"; import DummyLandscapeImage from "./_ShareComponent/DummyLandscapeImage"; import GridComponentView from "./_ShareComponent/GridSectionView"; +import NewWrapper from "./_ShareComponent/NewWrapper"; // Progress import ProgressCustom from "./Progress/ProgressCustom"; // Loader @@ -119,6 +120,7 @@ export { DummyLandscapeImage, GridComponentView, Spacing, + NewWrapper, // Stack StackCustom, TabBarBackground, diff --git a/hooks/use-paginated-api.ts b/hooks/use-paginated-api.ts new file mode 100644 index 0000000..b106973 --- /dev/null +++ b/hooks/use-paginated-api.ts @@ -0,0 +1,97 @@ +// @/hooks/use-paginated-api.ts +import { useCallback, useState, useEffect, useRef } from "react"; + +interface UsePaginatedApiProps { + fetcher: (params: { page: number; search?: string }) => Promise; + initialSearch?: string; // mengatur nilai awal dari state + pageSize?: number; + dependencies?: any[]; // untuk refresh saat deps berubah (misal: user.id) +} + +export const usePaginatedApi = ({ + fetcher, + initialSearch = "", + pageSize = 5, + dependencies = [], +}: UsePaginatedApiProps) => { + const [data, setData] = useState([]); + const [loading, setLoading] = useState(true); + const [refreshing, setRefreshing] = useState(false); + const [hasMore, setHasMore] = useState(true); + const [page, setPage] = useState(1); + const [search, setSearch] = useState(initialSearch); + const refreshingRef = useRef(false); + const loadingRef = useRef(false); + const fetchRef = useRef(false); + + const fetchData = useCallback( + async (pageNumber: number, clear: boolean) => { + const isRefresh = clear; + + // 🔒 Proteksi: jangan jalankan jika sedang refreshing (untuk refresh) + if (isRefresh && refreshingRef.current) return; + // 🔒 Proteksi: jangan jalankan loadMore jika sedang loading + if (!isRefresh && loadingRef.current) return; + + const setLoadingState = (isLoading: boolean) => { + if (isRefresh) { + setRefreshing(isLoading); + refreshingRef.current = isLoading; + } else { + setLoading(isLoading); + loadingRef.current = isLoading; + } + }; + + setLoadingState(true); + + try { + const newData = await fetcher({ page: pageNumber, search }); + setData((prev) => { + const current = Array.isArray(prev) ? prev : []; + return clear ? newData : [...current, ...newData]; + }); + setHasMore(newData.length === pageSize); + setPage(pageNumber); + } catch (error) { + console.error("[usePaginatedApi] Error:", error); + setHasMore(false); + } finally { + setLoadingState(false); + } + }, + [search, hasMore, pageSize, ...dependencies] +); + + const onRefresh = useCallback(() => { + fetchData(1, true); + }, [fetchData]); + + const loadMore = useCallback(() => { + if (hasMore && !loading && !refreshing) { + fetchData(page + 1, false); + } + }, [hasMore, loading, refreshing, page, fetchData]); + + // Reset & fetch ulang saat search atau deps berubah + useEffect(() => { + if (fetchRef.current) return; // hindari double initial + fetchRef.current = true; + + setPage(1); + setData([]); + setHasMore(true); + fetchData(1, true); + }, [search, ...dependencies]); + + return { + data, + loading, + refreshing, + hasMore, + search, + setSearch, + onRefresh, + loadMore, + }; +}; diff --git a/screens/Profile/ListPage.tsx b/screens/Profile/ListPage.tsx index 628accd..2a0eb0a 100644 --- a/screens/Profile/ListPage.tsx +++ b/screens/Profile/ListPage.tsx @@ -62,6 +62,18 @@ export const drawerItemsProfile = ({ path: `/(application)/portofolio/${id}/create`, value: "create-portofolio", }, + { + icon: ( + + ), + label: "Blocked List", + path: `/(application)/profile/${id}/blocked-list`, + value: "blocked-list", + }, { icon: ( + ), + label: "Blocked List", + path: `/(application)/profile/${id}/blocked-list`, + value: "blocked-list", }, { icon: ( diff --git a/service/api-client/api-blocked.ts b/service/api-client/api-blocked.ts new file mode 100644 index 0000000..43e5f2a --- /dev/null +++ b/service/api-client/api-blocked.ts @@ -0,0 +1,45 @@ +import { apiConfig } from "../api-config"; + +/** + * @param id | Profile ID + * @param search | Search Query + * @param page | Page Number + */ +export async function apiGetBlocked({ + id, + search, + page, +}: { + id: string; + search?: string; + page?: string; +}) { + const pageQuery = page ? `&page=${page}` : ""; + const searchQuery = search ? `&search=${search}` : ""; + try { + const response = await apiConfig.get( + `/mobile/block-user?id=${id}${pageQuery}${searchQuery}` + ); + return response.data; + } catch (error) { + throw error; + } +} + +export async function apiGetBlockedById({ id }: { id: string }) { + try { + const response = await apiConfig.get(`/mobile/block-user/${id}`); + return response.data; + } catch (error) { + throw error; + } +} + +export async function apiUnblock({ id }: { id: string }) { + try { + const response = await apiConfig.delete(`/mobile/block-user/${id}`); + return response.data; + } catch (error) { + throw error; + } +} \ No newline at end of file diff --git a/styles/global-styles.ts b/styles/global-styles.ts index 553339c..1d6a538 100644 --- a/styles/global-styles.ts +++ b/styles/global-styles.ts @@ -16,7 +16,7 @@ export const GStyles = StyleSheet.create({ // =============== Main Styles =============== // container: { flex: 1, - paddingInline: PADDING_LARGE, + paddingInline: PADDING_MEDIUM, paddingBlock: PADDING_EXTRA_SMALL, backgroundColor: MainColor.darkblue, },