Fix Infinite Load Data

Forum & User Search – User
- app/(application)/(user)/forum/index.tsx
- app/(application)/(user)/user-search/index.tsx

Global & Core
- app/+not-found.tsx
- screens/RootLayout/AppRoot.tsx
- constants/constans-value.ts

Component
- components/Image/AvatarComp.tsx

API Client
- service/api-client/api-user.ts

Untracked Files
- QWEN.md
- helpers/
- hooks/use-pagination.tsx
- screens/Forum/ViewBeranda3.tsx
- screens/UserSeach/

### No Issue
This commit is contained in:
2026-01-29 11:36:24 +08:00
parent 71e45d06cc
commit b3bfbc0f7e
14 changed files with 1626 additions and 121 deletions

179
QWEN.md Normal file
View File

@@ -0,0 +1,179 @@
# HIPMI Mobile Application - Development Guide
## Project Overview
HIPMI Badung Connect is a mobile application built with Expo and React Native. It serves as a connection platform for HIPMI (Himpunan Pengusaha Muda Indonesia) Badung members, featuring authentication, user management, and various business-related functionalities.
### Key Technologies
- **Framework**: Expo (v54.0.0) with React Native (0.81.4)
- **Architecture**: File-based routing with Expo Router
- **State Management**: React Context API
- **Styling**: React Native components with custom color palettes
- **Authentication**: Token-based authentication with OTP verification
- **Database**: AsyncStorage for local storage
- **Maps**: React Native Maps and Mapbox integration
- **Notifications**: Expo Notifications and Firebase Messaging
- **Language**: TypeScript
### Project Structure
```
hipmi-mobile/
├── app/ # File-based routing structure
│ ├── (application)/ # Main application screens
│ │ ├── (file)/ # File management screens
│ │ ├── (image)/ # Image management screens
│ │ ├── (user)/ # User-specific screens
│ │ └── admin/ # Admin-specific screens
│ ├── _layout.tsx # Root layout wrapper
│ ├── index.tsx # Home screen
│ ├── eula.tsx # Terms and conditions screen
│ ├── register.tsx # Registration screen
│ └── verification.tsx # OTP verification screen
├── assets/ # Static assets (images, icons)
├── components/ # Reusable UI components
├── constants/ # Configuration constants
├── context/ # React Context providers
├── hooks/ # Custom React hooks
├── screens/ # Screen components
├── service/ # API services and configurations
├── types/ # TypeScript type definitions
├── app.config.js # Expo configuration
├── package.json # Dependencies and scripts
└── ...
```
## Building and Running
### Prerequisites
- Node.js (with bun >=1.0.0 as specified in package.json)
- Expo CLI or bun installed globally
### Setup Instructions
1. **Install dependencies**:
```bash
bun install
# or if using npm
npm install
```
2. **Environment Variables**:
Create a `.env` file with the following variables:
```
API_BASE_URL=your_api_base_url
BASE_URL=your_base_url
DEEP_LINK_URL=your_deep_link_url
```
3. **Start the development server**:
```bash
# Using bun (as specified in package.json)
bun run start
# or using expo directly
npx expo start
```
4. **Platform-specific commands**:
```bash
# Android
bun run android
# iOS
bun run ios
# Web
bun run web
```
### EAS Build Configuration
The project uses Expo Application Services (EAS) for building and deployment:
- Development builds: `eas build --profile development`
- Preview builds: `eas build --profile preview`
- Production builds: `eas build --profile production`
## Authentication Flow
The application implements a phone number-based authentication system with OTP verification:
1. **Login**: User enters phone number → OTP sent via SMS
2. **Verification**: User enters OTP code → Validates and creates session
3. **Registration**: If user doesn't exist, registration flow is triggered
4. **Terms Agreement**: User must accept terms and conditions
5. **Session Management**: Tokens stored in AsyncStorage
### Key Authentication Functions
- `loginWithNomor()`: Initiates OTP sending
- `validateOtp()`: Verifies OTP and creates session
- `registerUser()`: Registers new users
- `logout()`: Clears session and removes tokens
- `acceptedTerms()`: Handles terms acceptance
## Key Features
### User Management
- Phone number-based registration and login
- OTP verification system
- Terms and conditions agreement
- User profile management
### Business Features
- Business field management (admin section)
- File and image management capabilities
- Location services integration
- Push notifications
### UI Components
- Custom color palette with blue/yellow theme
- Responsive layouts using SafeAreaView
- Toast notifications for user feedback
- Bottom tab navigation and drawer navigation
## Development Conventions
### Naming Conventions
- Components: PascalCase (e.g., `UserProfile.tsx`)
- Functions: camelCase (e.g., `getUserData()`)
- Constants: UPPER_SNAKE_CASE (e.g., `API_BASE_URL`)
- Files: kebab-case or camelCase for utility files
### Code Organization
- Components are organized by feature/functionality
- API services are centralized in the `service/` directory
- Type definitions are maintained in the `types/` directory
- Constants are grouped by category in the `constants/` directory
### Styling Approach
- Color palette defined in `constants/color-palet.ts`
- Reusable styles and themes centralized
- Responsive design using React Native's flexbox system
### Testing
- Linting: `bun run lint` (uses ESLint with Expo config)
- No specific test framework mentioned in package.json
## Environment Configuration
The application supports multiple environments through:
- Environment variables loaded via dotenv
- Expo's extra configuration in `app.config.js`
- Platform-specific configurations for iOS and Android
### Supported Platforms
- iOS (with tablet support)
- Android (with adaptive icons)
- Web (static output)
## Third-party Integrations
- **Firebase**: Authentication, messaging, and analytics
- **Mapbox**: Advanced mapping capabilities
- **React Navigation**: Screen navigation and routing
- **React Native Paper**: Material Design components
- **Axios**: HTTP client for API requests
- **Lodash**: Utility functions
- **QR Code SVG**: QR code generation
## Important Configuration Files
- `app.config.js`: Expo configuration, app metadata, and plugin setup
- `eas.json`: EAS build profiles and submission configuration
- `tsconfig.json`: TypeScript compiler options
- `package.json`: Dependencies, scripts, and project metadata
- `metro.config.js`: Metro bundler configuration

View File

@@ -1,12 +1,14 @@
/* eslint-disable react-hooks/exhaustive-deps */
import Forum_ViewBeranda from "@/screens/Forum/ViewBeranda";
import Forum_ViewBeranda2 from "@/screens/Forum/ViewBeranda2";
import Forum_ViewBeranda3 from "@/screens/Forum/ViewBeranda3";
export default function Forum() {
return (
<>
{/* <Forum_ViewBeranda /> */}
<Forum_ViewBeranda2 />
{/* <Forum_ViewBeranda2 /> */}
<Forum_ViewBeranda3 />
</>
);
}

View File

@@ -1,115 +1,11 @@
import {
AvatarComp,
ClickableCustom,
Grid,
LoaderCustom,
Spacing,
StackCustom,
TextCustom,
TextInputCustom,
ViewWrapper,
} from "@/components";
import { MainColor } from "@/constants/color-palet";
import { ICON_SIZE_SMALL } from "@/constants/constans-value";
import { apiAllUser } from "@/service/api-client/api-user";
import { Ionicons } from "@expo/vector-icons";
import { router } from "expo-router";
import _ from "lodash";
import { useEffect, useState } from "react";
import UserSearchMainView from "@/screens/UserSeach/MainView";
import UserSearchMainView_V2 from "@/screens/UserSeach/MainView_V2";
export default function UserSearch() {
const [data, setData] = useState<any[]>([]);
const [search, setSearch] = useState<string>("");
const [isLoadList, setIsLoadList] = useState(false);
useEffect(() => {
onLoadData(search);
}, [search]);
const onLoadData = async (search: string) => {
try {
setIsLoadList(true);
const response = await apiAllUser({ search: search });
console.log("[DATA USER] >", JSON.stringify(response.data, null, 2));
setData(response.data);
} catch (error) {
console.log("Error fetching data", error);
} finally {
setIsLoadList(false);
}
};
const handleSearch = (search: string) => {
setSearch(search);
onLoadData(search);
};
return (
<>
<ViewWrapper
headerComponent={
<TextInputCustom
value={search}
onChangeText={handleSearch}
iconLeft={
<Ionicons
name="search"
size={ICON_SIZE_SMALL}
color={MainColor.placeholder}
/>
}
placeholder="Cari Pengguna"
borderRadius={50}
containerStyle={{ marginBottom: 0 }}
/>
}
>
<StackCustom>
{isLoadList ? (
<LoaderCustom />
) : !_.isEmpty(data) ? (
data?.map((e, index) => {
return (
<ClickableCustom
key={index}
onPress={() => {
console.log("Ke Profile");
router.push(`/profile/${e?.Profile?.id}`);
}}
>
<Grid>
<Grid.Col span={2}>
<AvatarComp fileId={e?.Profile?.imageId} size="base" />
</Grid.Col>
<Grid.Col span={9}>
<StackCustom gap={"sm"}>
<TextCustom size="large">{e?.username}</TextCustom>
<TextCustom size="small">+{e?.nomor}</TextCustom>
</StackCustom>
</Grid.Col>
<Grid.Col
span={1}
style={{
justifyContent: "center",
alignItems: "flex-end",
}}
>
<Ionicons
name="chevron-forward"
size={ICON_SIZE_SMALL}
color={MainColor.white}
/>
</Grid.Col>
</Grid>
</ClickableCustom>
);
})
) : (
<TextCustom align="center">Tidak ditemukan</TextCustom>
)}
</StackCustom>
<Spacing height={50} />
</ViewWrapper>
{/* <UserSearchMainView /> */}
<UserSearchMainView_V2 />
</>
);
}

View File

@@ -5,7 +5,7 @@ export default function NotFoundScreen() {
return (
<>
<Stack.Screen
options={{ headerShown: false, headerLeft: () => <BackButton /> }}
options={{ headerShown: true, title: "", headerLeft: () => <BackButton /> }}
/>
<ViewWrapper>
<StackCustom
@@ -17,7 +17,7 @@ export default function NotFoundScreen() {
404
</TextCustom>
<TextCustom size="large" bold>
Sorry, File Not Found
Sorry, Page Not Found
</TextCustom>
</StackCustom>
</ViewWrapper>

View File

@@ -52,7 +52,7 @@ export default function AvatarComp({
<TouchableOpacity
activeOpacity={0.9}
onPress={
href && fileId ? () => router.navigate(href as any) : onPress
href || fileId ? () => router.navigate(href as any) : onPress
}
>
{avatarImage()}

View File

@@ -19,6 +19,7 @@ export {
PADDING_SMALL,
PADDING_MEDIUM,
PADDING_LARGE,
PAGINATION_DEFAULT_TAKE
};
// OS Height
@@ -51,3 +52,5 @@ const PADDING_SMALL = 12
const PADDING_MEDIUM = 16
const PADDING_LARGE = 20
// Pagination
const PAGINATION_DEFAULT_TAKE = 10;

517
helpers/PaginationGuide.md Normal file
View File

@@ -0,0 +1,517 @@
# 📱 Reusable Pagination untuk React Native + Expo
Komponen pagination yang terintegrasi dengan **NewWrapper** untuk infinite scroll, pull-to-refresh, skeleton loading, dan empty state.
---
## 📦 File Structure
```
/hooks/
└── usePagination.tsx # Custom hook untuk logika pagination
/helpers/
└── paginationHelpers.tsx # Helper functions untuk komponen UI
/components/
└── NewWrapper.tsx # Komponen wrapper utama (existing)
```
---
## 🚀 Cara Penggunaan
### **Step 1: Import Hook dan Helpers**
```tsx
import { usePagination } from "@/hooks/usePagination";
import { createPaginationComponents } from "@/helpers/paginationHelpers";
import NewWrapper from "@/components/_ShareComponent/NewWrapper";
```
### **Step 2: Setup Pagination Hook**
```tsx
const pagination = usePagination({
// ✅ Fungsi untuk fetch data (harus return { data: T[] })
fetchFunction: async (page, searchQuery) => {
return await apiForumGetAll({
category: "beranda",
search: searchQuery || "",
userLoginId: user.id,
page: String(page),
});
},
// ✅ Page size (harus sama dengan API)
pageSize: 5,
// ✅ Query pencarian
searchQuery: search,
// ✅ Dependencies (reload saat berubah)
dependencies: [user?.id, category],
// ⚙️ Optional callbacks
onDataFetched: (data) => console.log("Loaded:", data.length),
onError: (error) => console.error("Error:", error),
});
```
### **Step 3: Generate Komponen Pagination**
```tsx
const { ListEmptyComponent, ListFooterComponent } = createPaginationComponents({
loading: pagination.loading,
refreshing: pagination.refreshing,
listData: pagination.listData,
searchQuery: search,
emptyMessage: "Tidak ada data",
emptySearchMessage: "Tidak ada hasil pencarian",
skeletonCount: 5,
skeletonHeight: 200,
});
```
### **Step 4: Gunakan dengan NewWrapper**
```tsx
<NewWrapper
// Props dari pagination hook
listData={pagination.listData}
refreshControl={
<RefreshControl
refreshing={pagination.refreshing}
onRefresh={pagination.onRefresh}
/>
}
onEndReached={pagination.loadMore}
// Komponen dari helpers
ListEmptyComponent={ListEmptyComponent}
ListFooterComponent={ListFooterComponent}
// Render item
renderItem={({ item }) => <YourComponent data={item} />}
// Props lain dari NewWrapper
headerComponent={<SearchInput />}
floatingButton={<FloatingButton />}
/>
```
---
## 📖 Contoh Implementasi Lengkap
### **Contoh 1: Forum Page (Basic)**
```tsx
import { usePagination } from "@/hooks/usePagination";
import { createPaginationComponents } from "@/helpers/paginationHelpers";
import NewWrapper from "@/components/_ShareComponent/NewWrapper";
import { MainColor } from "@/constants/color-palet";
export default function ForumPage() {
const { user } = useAuth();
const [search, setSearch] = useState("");
// Setup pagination
const pagination = usePagination({
fetchFunction: async (page, searchQuery) => {
if (!user?.id) return { data: [] };
return await apiForumGetAll({
category: "beranda",
search: searchQuery || "",
userLoginId: user.id,
page: String(page),
});
},
pageSize: 5,
searchQuery: search,
dependencies: [user?.id],
});
// Generate komponen
const { ListEmptyComponent, ListFooterComponent } = createPaginationComponents({
loading: pagination.loading,
refreshing: pagination.refreshing,
listData: pagination.listData,
searchQuery: search,
emptyMessage: "Tidak ada diskusi",
emptySearchMessage: "Tidak ada hasil pencarian",
});
return (
<NewWrapper
headerComponent={
<SearchInput
placeholder="Cari diskusi..."
onChangeText={_.debounce(setSearch, 500)}
/>
}
listData={pagination.listData}
renderItem={({ item }) => <ForumItem data={item} />}
refreshControl={
<RefreshControl
tintColor={MainColor.yellow}
refreshing={pagination.refreshing}
onRefresh={pagination.onRefresh}
/>
}
onEndReached={pagination.loadMore}
ListEmptyComponent={ListEmptyComponent}
ListFooterComponent={ListFooterComponent}
/>
);
}
```
### **Contoh 2: Product Page (Dengan Filter)**
```tsx
export default function ProductPage() {
const [search, setSearch] = useState("");
const [category, setCategory] = useState("all");
const pagination = usePagination({
fetchFunction: async (page, searchQuery) => {
return await apiProductGetAll({
page: String(page),
search: searchQuery || "",
category: category !== "all" ? category : undefined,
});
},
pageSize: 10,
searchQuery: search,
dependencies: [category], // Reload saat category berubah
});
const { ListEmptyComponent, ListFooterComponent } = createPaginationComponents({
loading: pagination.loading,
refreshing: pagination.refreshing,
listData: pagination.listData,
searchQuery: search,
emptyMessage: "Belum ada produk",
skeletonCount: 8,
skeletonHeight: 100,
});
return (
<NewWrapper
headerComponent={
<View>
<SearchInput onChangeText={setSearch} />
<CategoryFilter value={category} onChange={setCategory} />
</View>
}
listData={pagination.listData}
renderItem={({ item }) => <ProductCard product={item} />}
refreshControl={
<RefreshControl
refreshing={pagination.refreshing}
onRefresh={pagination.onRefresh}
/>
}
onEndReached={pagination.loadMore}
ListEmptyComponent={ListEmptyComponent}
ListFooterComponent={ListFooterComponent}
/>
);
}
```
---
## ⚙️ API Reference
### **usePagination Hook**
#### Props
| Prop | Type | Required | Default | Deskripsi |
|------|------|----------|---------|-----------|
| `fetchFunction` | `(page, search?) => Promise<{data: T[]}>` | ✅ | - | Fungsi fetch data dari API |
| `pageSize` | `number` | ❌ | `5` | Jumlah data per halaman |
| `searchQuery` | `string` | ❌ | `""` | Query pencarian |
| `dependencies` | `any[]` | ❌ | `[]` | Dependencies untuk trigger reload |
| `onDataFetched` | `(data: T[]) => void` | ❌ | - | Callback saat data berhasil di-fetch |
| `onError` | `(error: any) => void` | ❌ | - | Callback saat terjadi error |
#### Return Value
```tsx
{
listData: T[]; // Array data untuk NewWrapper
loading: boolean; // Loading state
refreshing: boolean; // Refreshing state
hasMore: boolean; // Apakah masih ada data
page: number; // Current page
onRefresh: () => void; // Function untuk refresh
loadMore: () => void; // Function untuk load more
reset: () => void; // Function untuk reset state
setListData: (data) => void; // Function untuk set data manual
}
```
---
### **createPaginationComponents Helper**
#### Props
| Prop | Type | Required | Default | Deskripsi |
|------|------|----------|---------|-----------|
| `loading` | `boolean` | ✅ | - | Loading state |
| `refreshing` | `boolean` | ✅ | - | Refreshing state |
| `listData` | `any[]` | ✅ | - | List data |
| `searchQuery` | `string` | ❌ | `""` | Query pencarian |
| `emptyMessage` | `string` | ❌ | `"Tidak ada data"` | Pesan empty state |
| `emptySearchMessage` | `string` | ❌ | `"Tidak ada hasil pencarian"` | Pesan empty saat search |
| `skeletonCount` | `number` | ❌ | `5` | Jumlah skeleton items |
| `skeletonHeight` | `number` | ❌ | `200` | Tinggi skeleton items |
| `loadingFooterText` | `string` | ❌ | - | Text loading footer |
#### Return Value
```tsx
{
ListEmptyComponent: React.ReactElement; // Component untuk empty state
ListFooterComponent: React.ReactElement; // Component untuk loading footer
}
```
---
### **Helper Functions Lain**
#### `createSkeletonList(options)`
Generate skeleton list untuk loading state.
```tsx
const SkeletonComponent = createSkeletonList({
count: 5,
height: 200
});
```
#### `createEmptyState(options)`
Generate empty state component.
```tsx
const EmptyComponent = createEmptyState({
message: "Tidak ada data",
searchMessage: "Tidak ada hasil pencarian",
searchQuery: search
});
```
#### `createLoadingFooter(options)`
Generate loading footer component.
```tsx
const FooterComponent = createLoadingFooter({
show: loading && listData.length > 0,
text: "Memuat data..."
});
```
---
## 🎨 Custom Components
### **Custom Empty State**
```tsx
import { createSkeletonList } from "@/helpers/paginationHelpers";
const CustomEmpty = (
<View style={{ flex: 1, justifyContent: "center", alignItems: "center" }}>
<Text>🔍</Text>
<TextCustom>Data tidak ditemukan</TextCustom>
</View>
);
const ListEmptyComponent =
pagination.loading && pagination.listData.length === 0
? createSkeletonList({ count: 5, height: 200 })
: CustomEmpty;
<NewWrapper
ListEmptyComponent={ListEmptyComponent}
// ...
/>
```
### **Custom Loading Footer**
```tsx
import { createLoadingFooter } from "@/helpers/paginationHelpers";
const CustomFooter = createLoadingFooter({
show: pagination.loading && !pagination.refreshing && pagination.listData.length > 0,
customComponent: (
<View style={{ padding: 20, alignItems: "center" }}>
<ActivityIndicator size="large" color="#007AFF" />
<Text style={{ marginTop: 8 }}>Loading more...</Text>
</View>
)
});
<NewWrapper
ListFooterComponent={CustomFooter}
// ...
/>
```
---
## ✨ Fitur-Fitur
**Infinite Scroll** - Auto load saat scroll ke bawah
**Pull to Refresh** - Swipe down untuk refresh
**Skeleton Loading** - Smooth loading animation
**Empty State** - Tampilan saat data kosong
**Search Integration** - Support search dengan debounce
**Multi Dependencies** - Reload berdasarkan filter apapun
**Error Handling** - Built-in error handling
**TypeScript** - Full type safety
**Fully Customizable** - Custom components untuk semua state
---
## 🎯 Best Practices
### 1. **Gunakan Debounce untuk Search**
```tsx
<SearchInput
onChangeText={_.debounce((text) => setSearch(text), 500)}
/>
```
### 2. **Sesuaikan Page Size dengan API**
```tsx
const pagination = usePagination({
pageSize: 5, // Harus sama dengan takeData di API
});
```
### 3. **Tambahkan Dependencies yang Relevan**
```tsx
const pagination = usePagination({
dependencies: [userId, category, sortBy], // Reload saat berubah
});
```
### 4. **Handle Error dengan Baik**
```tsx
const pagination = usePagination({
onError: (error) => {
console.error("Error:", error);
Alert.alert("Error", "Gagal memuat data");
},
});
```
### 5. **Pastikan API Return Format yang Benar**
```tsx
// ❌ SALAH
fetchFunction: async () => [data1, data2];
// ✅ BENAR
fetchFunction: async () => ({ data: [data1, data2] });
```
---
## 🔧 Troubleshooting
### **Data tidak muncul?**
- Pastikan `fetchFunction` return `{ data: T[] }`
- Cek apakah API return format yang benar
- Pastikan `pageSize` sesuai dengan API
### **Infinite scroll tidak jalan?**
- Pastikan API return data sesuai `pageSize`
- Cek `hasMore` state
- Pastikan `onEndReachedThreshold` tidak terlalu kecil (default 0.5)
### **Skeleton terus muncul?**
- Cek `loading` state
- Pastikan `fetchFunction` resolve dengan benar
- Cek error di console
### **Refresh tidak bekerja?**
- Pastikan `RefreshControl` menggunakan `pagination.refreshing` dan `pagination.onRefresh`
- Cek apakah API dipanggil saat pull-to-refresh
---
## 📝 Migration Guide
### **Dari Code Lama ke Code Baru**
#### **BEFORE:**
```tsx
const [listData, setListData] = useState([]);
const [loading, setLoading] = useState(false);
const [refreshing, setRefreshing] = useState(false);
const [hasMore, setHasMore] = useState(true);
const [page, setPage] = useState(1);
const fetchData = async (pageNumber, clear) => {
// ... 30+ lines of code
};
useEffect(() => {
setPage(1);
setListData([]);
setHasMore(true);
fetchData(1, true);
}, [search, user?.id]);
const onRefresh = useCallback(() => {
fetchData(1, true);
}, [search, user?.id]);
const loadMore = useCallback(() => {
if (hasMore && !loading && !refreshing) {
fetchData(page + 1, false);
}
}, [hasMore, loading, refreshing, page, search, user?.id]);
// ... skeleton, empty, footer components
```
#### **AFTER:**
```tsx
const pagination = usePagination({
fetchFunction: async (page, search) => await apiGetData({ page, search }),
pageSize: 5,
searchQuery: search,
dependencies: [user?.id]
});
const { ListEmptyComponent, ListFooterComponent } = createPaginationComponents({
loading: pagination.loading,
refreshing: pagination.refreshing,
listData: pagination.listData,
searchQuery: search,
});
```
**Result:** 50+ lines → 15 lines! 🎉
---
## 👨‍💻 Author
Created by Full-Stack Developer
React Native + Expo Specialist
---
## 📄 License
MIT License - Feel free to use in your projects!

View File

@@ -0,0 +1,280 @@
import { View } from "react-native";
import { LoaderCustom, TextCustom, StackCustom } from "@/components";
import SkeletonCustom from "@/components/_ShareComponent/SkeletonCustom";
import _ from "lodash";
/**
* Pagination Helpers
*
* Helper functions untuk membuat komponen-komponen pagination
* yang sering digunakan (Skeleton, Empty State, Loading Footer)
*/
interface SkeletonListOptions {
/**
* Jumlah skeleton items
* @default 5
*/
count?: number;
/**
* Tinggi setiap skeleton item
* @default 200
*/
height?: number;
}
/**
* Generate Skeleton List Component untuk loading state
*
* @example
* ```tsx
* <NewWrapper
* listData={listData}
* ListEmptyComponent={
* loading && _.isEmpty(listData)
* ? createSkeletonList({ count: 5, height: 200 })
* : createEmptyState({ message: "Tidak ada data" })
* }
* />
* ```
*/
export const createSkeletonList = (options: SkeletonListOptions = {}) => {
const { count = 5, height = 200 } = options;
return (
<View style={{ flex: 1 }}>
<StackCustom>
{Array.from({ length: count }).map((_, i) => (
<SkeletonCustom height={height} key={i} />
))}
</StackCustom>
</View>
);
};
interface EmptyStateOptions {
/**
* Pesan untuk empty state
* @default "Tidak ada data"
*/
message?: string;
/**
* Pesan untuk empty state saat search
*/
searchMessage?: string;
/**
* Query pencarian (untuk menentukan pesan mana yang ditampilkan)
*/
searchQuery?: string;
/**
* Custom component untuk empty state
*/
customComponent?: React.ReactElement;
}
/**
* Generate Empty State Component
*
* @example
* ```tsx
* ListEmptyComponent={
* createEmptyState({
* message: "Tidak ada diskusi",
* searchMessage: "Tidak ada hasil pencarian",
* searchQuery: search
* })
* }
* ```
*/
export const createEmptyState = (options: EmptyStateOptions = {}) => {
const {
message = "Tidak ada data",
searchMessage = "Tidak ada hasil pencarian",
searchQuery = "",
customComponent,
} = options;
if (customComponent) return customComponent;
return (
<View
style={{
flex: 1,
justifyContent: "center",
alignItems: "center",
padding: 20,
}}
>
<TextCustom align="center" color="gray">
{searchQuery ? searchMessage : message}
</TextCustom>
</View>
);
};
interface LoadingFooterOptions {
/**
* Tampilkan loading footer
*/
show: boolean;
/**
* Custom text untuk loading footer
*/
text?: string;
/**
* Custom component untuk loading footer
*/
customComponent?: React.ReactElement;
}
/**
* Generate Loading Footer Component
*
* @example
* ```tsx
* ListFooterComponent={
* createLoadingFooter({
* show: loading && !refreshing && listData.length > 0,
* text: "Memuat data..."
* })
* }
* ```
*/
export const createLoadingFooter = (options: LoadingFooterOptions) => {
const { show, text, customComponent } = options;
if (!show) return null;
if (customComponent) return customComponent;
return (
<View style={{ paddingVertical: 16, alignItems: "center" }}>
{text ? (
<TextCustom color="gray">
{text}
</TextCustom>
) : (
<LoaderCustom />
)}
</View>
);
};
interface PaginationComponentsOptions {
/**
* Loading state
*/
loading: boolean;
/**
* Refreshing state
*/
refreshing: boolean;
/**
* List data
*/
listData: any[];
/**
* Query pencarian
*/
searchQuery?: string;
/**
* Pesan empty state
*/
emptyMessage?: string;
/**
* Pesan empty state saat search
*/
emptySearchMessage?: string;
/**
* Jumlah skeleton items
*/
skeletonCount?: number;
/**
* Tinggi skeleton items
*/
skeletonHeight?: number;
/**
* Text loading footer
*/
loadingFooterText?: string;
/**
* Loading pertama
*/
isInitialLoad?: boolean;
}
/**
* Generate semua komponen pagination sekaligus
*
* @example
* ```tsx
* const { ListEmptyComponent, ListFooterComponent } = createPaginationComponents({
* loading,
* refreshing,
* listData,
* searchQuery: search,
* emptyMessage: "Tidak ada diskusi",
* emptySearchMessage: "Tidak ada hasil pencarian",
* skeletonCount: 5,
* skeletonHeight: 200
* });
*
* <NewWrapper
* listData={listData}
* ListEmptyComponent={ListEmptyComponent}
* ListFooterComponent={ListFooterComponent}
* />
* ```
*/
export const createPaginationComponents = (
options: PaginationComponentsOptions
) => {
const {
loading,
refreshing,
listData,
searchQuery = "",
emptyMessage = "Tidak ada data",
emptySearchMessage = "Tidak ada hasil pencarian",
skeletonCount = 5,
skeletonHeight = 200,
loadingFooterText,
isInitialLoad,
} = options;
// Empty Compotnent: Skeleton saat loading pertama, Empty State saat data kosong
const ListEmptyComponent =
loading && _.isEmpty(listData)
? createSkeletonList({ count: skeletonCount, height: skeletonHeight })
: createEmptyState({
message: emptyMessage,
searchMessage: emptySearchMessage,
searchQuery,
});
// Footer Component: Loading indicator saat load more
const ListFooterComponent = createLoadingFooter({
show: loading && !refreshing && listData.length > 0,
text: loadingFooterText,
});
return {
ListEmptyComponent,
ListFooterComponent,
};
};

184
hooks/use-pagination.tsx Normal file
View File

@@ -0,0 +1,184 @@
import { useState, useCallback, useEffect } from "react";
interface UsePaginationProps<T> {
/**
* Fungsi API untuk fetch data
* @param page - nomor halaman
* @param search - query pencarian (opsional)
* @returns Promise dengan response API (bukan langsung array)
*/
fetchFunction: (page: number, search?: string) => Promise<{ data: T[] }>;
/**
* Jumlah data per halaman (harus sama dengan API)
* @default 5
*/
pageSize?: number;
/**
* Query pencarian
*/
searchQuery?: string;
/**
* Dependencies tambahan untuk trigger reload
* Contoh: [userId, categoryId]
*/
dependencies?: any[];
/**
* Callback saat data berhasil di-fetch
*/
onDataFetched?: (data: T[]) => void;
/**
* Callback saat terjadi error
*/
onError?: (error: any) => void;
}
interface UsePaginationReturn<T> {
// Data state
listData: T[];
loading: boolean;
refreshing: boolean;
hasMore: boolean;
page: number;
// Actions
onRefresh: () => void;
loadMore: () => void;
reset: () => void;
setListData: React.Dispatch<React.SetStateAction<T[]>>;
isInitialLoad: boolean;
}
/**
* Custom Hook untuk menangani pagination dengan infinite scroll
*
* Hook ini mengembalikan props yang siap digunakan langsung dengan NewWrapper
*
* @example
* ```tsx
* const pagination = usePagination({
* fetchFunction: async (page, search) => {
* return await apiForumGetAll({
* category: "beranda",
* search: search || "",
* userLoginId: user.id,
* page: String(page),
* });
* },
* pageSize: 5,
* searchQuery: search,
* dependencies: [user?.id]
* });
*
* // Lalu gunakan langsung di NewWrapper:
* <NewWrapper
* listData={pagination.listData}
* refreshControl={<RefreshControl refreshing={pagination.refreshing} onRefresh={pagination.onRefresh} />}
* onEndReached={pagination.loadMore}
* // ... props lainnya
* />
* ```
*/
export function usePagination<T = any>({
fetchFunction,
pageSize = 5,
searchQuery = "",
dependencies = [],
onDataFetched,
onError,
}: UsePaginationProps<T>): UsePaginationReturn<T> {
const [listData, setListData] = useState<T[]>([]);
const [loading, setLoading] = useState(true); // Set true untuk initial load
const [isInitialLoad, setIsInitialLoad] = useState(true); // Track initial load
const [refreshing, setRefreshing] = useState(false);
const [hasMore, setHasMore] = useState(true);
const [page, setPage] = useState(1);
/**
* Fungsi utama untuk fetch data
*/
const fetchData = async (pageNumber: number, clear: boolean) => {
// Cegah multiple call
if (!clear && (loading || refreshing)) return;
const isRefresh = clear;
if (isRefresh) setRefreshing(true);
if (!isRefresh) setLoading(true);
try {
const response = await fetchFunction(pageNumber, searchQuery);
const newData = response.data || [];
// console.log("newData", newData);
setListData((prev) => {
const current = Array.isArray(prev) ? prev : [];
return clear ? newData : [...current, ...newData];
});
// setTimeout(() => {
// }, 4000);
setHasMore(newData.length === pageSize);
setPage(pageNumber);
// Callback jika ada
onDataFetched?.(newData);
} catch (error) {
console.error("[usePagination] Error fetching data:", error);
setHasMore(false);
onError?.(error);
} finally {
setRefreshing(false);
setLoading(false);
setIsInitialLoad(false); // Set false setelah initial load
}
};
/**
* Reset dan reload saat search atau dependencies berubah
*/
useEffect(() => {
reset();
fetchData(1, true);
}, [searchQuery, ...dependencies]);
/**
* Pull-to-refresh
*/
const onRefresh = useCallback(() => {
fetchData(1, true);
}, [searchQuery, ...dependencies]);
/**
* Load more (infinite scroll)
*/
const loadMore = useCallback(() => {
if (hasMore && !loading && !refreshing) {
fetchData(page + 1, false);
}
}, [hasMore, loading, refreshing, page, searchQuery, ...dependencies]);
/**
* Reset state pagination
*/
const reset = useCallback(() => {
setPage(1);
setListData([]);
setHasMore(true);
}, []);
return {
listData,
loading,
refreshing,
hasMore,
page,
onRefresh,
loadMore,
reset,
setListData,
isInitialLoad
};
}

View File

@@ -0,0 +1,132 @@
import {
AvatarComp,
BackButton,
FloatingButton,
SearchInput,
TextCustom,
} from "@/components";
import NewWrapper from "@/components/_ShareComponent/NewWrapper";
import { useAuth } from "@/hooks/use-auth";
import { createPaginationComponents } from "@/helpers/paginationHelpers";
import Forum_BoxDetailSection from "@/screens/Forum/DiscussionBoxSection";
import { apiForumGetAll } from "@/service/api-client/api-forum";
import { apiUser } from "@/service/api-client/api-user";
import { router, Stack } from "expo-router";
import _ from "lodash";
import { useEffect, useState } from "react";
import { RefreshControl, View } from "react-native";
import { MainColor } from "@/constants/color-palet";
import { usePagination } from "@/hooks/use-pagination";
const PAGE_SIZE = 5;
export default function Forum_ViewBeranda3() {
const { user } = useAuth();
const [dataUser, setDataUser] = useState<any>(null);
const [search, setSearch] = useState("");
// Load data profil user
useEffect(() => {
if (user?.id) {
apiUser(user.id).then((res) => setDataUser(res.data));
}
}, [user?.id]);
// Setup pagination (menggantikan 50+ lines code!)
const pagination = usePagination({
fetchFunction: async (page, searchQuery) => {
if (!user?.id) return { data: [] };
return await apiForumGetAll({
category: "beranda",
search: searchQuery || "",
userLoginId: user.id,
page: String(page),
});
},
pageSize: PAGE_SIZE,
searchQuery: search,
dependencies: [user?.id],
onError: (error) => console.error("[ERROR] Fetch forum:", error),
});
// Generate komponen (menggantikan 40+ lines code!)
const { ListEmptyComponent, ListFooterComponent } = createPaginationComponents({
loading: pagination.loading,
refreshing: pagination.refreshing,
listData: pagination.listData,
searchQuery: search,
emptyMessage: "Tidak ada diskusi",
emptySearchMessage: "Tidak ada hasil pencarian",
skeletonCount: 5,
skeletonHeight: 150,
});
// Render item forum
const renderForumItem = ({ item }: { item: any }) => (
<Forum_BoxDetailSection
key={item.id}
data={item}
onSetData={() => {}}
isTruncate={true}
href={`/forum/${item.id}`}
isRightComponent={false}
/>
);
// const ListHeaderComponent = (
// <View style={{ paddingVertical: 8, alignItems: "center" }}>
// <TextCustom>Diskusi Terbaru</TextCustom>
// </View>
// );
return (
<>
<Stack.Screen
options={{
title: "Forum",
headerLeft: () => <BackButton />,
headerRight: () => (
<AvatarComp
fileId={dataUser?.Profile?.imageId}
size="base"
href={`/forum/${user?.id}/forumku`}
/>
),
}}
/>
<NewWrapper
headerComponent={
<View style={{ paddingHorizontal: 16, paddingTop: 8 }}>
<SearchInput
placeholder="Cari topik diskusi"
onChangeText={_.debounce((text) => setSearch(text), 500)}
/>
</View>
}
floatingButton={
<FloatingButton
onPress={() =>
router.navigate("/(application)/(user)/forum/create")
}
/>
}
listData={pagination.listData}
renderItem={renderForumItem}
refreshControl={
<RefreshControl
tintColor={MainColor.yellow}
colors={[MainColor.yellow]}
refreshing={pagination.refreshing}
onRefresh={pagination.onRefresh}
/>
}
onEndReached={pagination.loadMore}
// ListHeaderComponent={ListHeaderComponent}
ListEmptyComponent={ListEmptyComponent}
ListFooterComponent={ListFooterComponent}
/>
</>
);
}

View File

@@ -1,3 +1,4 @@
import { BackButton } from "@/components";
import { MainColor } from "@/constants/color-palet";
import { Stack } from "expo-router";
@@ -15,8 +16,18 @@ export default function AppRoot() {
name="index"
options={{ title: "", headerBackVisible: false }}
/>
<Stack.Screen name="eula" options={{ title: "Terms & Conditions", headerBackVisible: false }} />
<Stack.Screen name="+not-found" options={{ title: "" }} />
<Stack.Screen
name="eula"
options={{ title: "Terms & Conditions", headerBackVisible: false }}
/>
{/* CEK PADA FILE */}
{/* <Stack.Screen
options={{
headerShown: true,
title: "",
headerLeft: () => <BackButton />,
}}
/> */}
<Stack.Screen
name="verification"
options={{ title: "", headerBackVisible: false }}

View File

@@ -0,0 +1,105 @@
import { ViewWrapper, TextInputCustom, StackCustom, LoaderCustom, ClickableCustom, Grid, AvatarComp, TextCustom, Spacing } from "@/components";
import { MainColor } from "@/constants/color-palet";
import { ICON_SIZE_SMALL } from "@/constants/constans-value";
import { apiAllUser } from "@/service/api-client/api-user";
import { Ionicons } from "@expo/vector-icons";
import { router } from "expo-router";
import _ from "lodash";
import { useState, useEffect } from "react";
export default function UserSearchMainView(){
const [data, setData] = useState<any[]>([]);
const [search, setSearch] = useState<string>("");
const [isLoadList, setIsLoadList] = useState(false);
useEffect(() => {
onLoadData(search);
}, [search]);
const onLoadData = async (search: string) => {
try {
setIsLoadList(true);
const response = await apiAllUser({ search: search });
console.log("[DATA USER] >", JSON.stringify(response.data, null, 2));
setData(response.data);
} catch (error) {
console.log("Error fetching data", error);
} finally {
setIsLoadList(false);
}
};
const handleSearch = (search: string) => {
setSearch(search);
onLoadData(search);
};
return (
<>
<ViewWrapper
headerComponent={
<TextInputCustom
value={search}
onChangeText={handleSearch}
iconLeft={
<Ionicons
name="search"
size={ICON_SIZE_SMALL}
color={MainColor.placeholder}
/>
}
placeholder="Cari Pengguna"
borderRadius={50}
containerStyle={{ marginBottom: 0 }}
/>
}
>
<StackCustom>
{isLoadList ? (
<LoaderCustom />
) : !_.isEmpty(data) ? (
data?.map((e, index) => {
return (
<ClickableCustom
key={index}
onPress={() => {
console.log("Ke Profile");
router.push(`/profile/${e?.Profile?.id}`);
}}
>
<Grid>
<Grid.Col span={2}>
<AvatarComp fileId={e?.Profile?.imageId} size="base" />
</Grid.Col>
<Grid.Col span={9}>
<StackCustom gap={"sm"}>
<TextCustom size="large">{e?.username}</TextCustom>
<TextCustom size="small">+{e?.nomor}</TextCustom>
</StackCustom>
</Grid.Col>
<Grid.Col
span={1}
style={{
justifyContent: "center",
alignItems: "flex-end",
}}
>
<Ionicons
name="chevron-forward"
size={ICON_SIZE_SMALL}
color={MainColor.white}
/>
</Grid.Col>
</Grid>
</ClickableCustom>
);
})
) : (
<TextCustom align="center">Tidak ditemukan</TextCustom>
)}
</StackCustom>
<Spacing height={50} />
</ViewWrapper>
</>
);
}

View File

@@ -0,0 +1,174 @@
import {
AvatarComp,
ClickableCustom,
Grid,
NewWrapper,
StackCustom,
TextCustom,
TextInputCustom
} from "@/components";
import { MainColor } from "@/constants/color-palet";
import { ICON_SIZE_SMALL, PAGINATION_DEFAULT_TAKE } from "@/constants/constans-value";
import { usePagination } from "@/hooks/use-pagination";
import { apiAllUser } from "@/service/api-client/api-user";
import { Ionicons } from "@expo/vector-icons";
import { router, useFocusEffect } from "expo-router";
import _ from "lodash";
import { useCallback, useRef, useState } from "react";
import { RefreshControl, View } from "react-native";
import { createPaginationComponents } from "@/helpers/paginationHelpers";
export default function UserSearchMainView_V2() {
const isInitialMount = useRef(true);
const [search, setSearch] = useState("");
const {
listData,
loading,
refreshing,
hasMore,
onRefresh,
loadMore,
isInitialLoad
} = usePagination({
fetchFunction: async (page, searchQuery) => {
const response = await apiAllUser({
page: String(page),
search: searchQuery || "",
});
return response;
},
pageSize: PAGINATION_DEFAULT_TAKE,
searchQuery: search,
});
// 🔁 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 renderHeader = () => (
<>
<TextInputCustom
value={search}
onChangeText={setSearch}
iconLeft={
<Ionicons
name="search"
size={ICON_SIZE_SMALL}
color={MainColor.placeholder}
/>
}
placeholder="Cari Pengguna"
borderRadius={50}
containerStyle={{ marginBottom: 0 }}
/>
</>
);
const renderItem = ({ item }: { item: any }) => (
<View
style={{
backgroundColor: MainColor.soft_darkblue,
borderRadius: 8,
padding: 12,
marginBottom: 10,
elevation: 2,
shadowColor: '#000',
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.2,
shadowRadius: 2,
// height: 100
}}
>
<ClickableCustom
onPress={() => {
console.log("Ke Profile");
router.push(`/profile/${item?.Profile?.id}`);
}}
>
<Grid>
<Grid.Col span={2}>
<AvatarComp fileId={item?.Profile?.imageId} size="base" />
</Grid.Col>
<Grid.Col span={9}>
<StackCustom gap={"sm"}>
<TextCustom size="large">{item?.username}</TextCustom>
<TextCustom size="small">+{item?.nomor}</TextCustom>
{item?.Profile?.businessField && (
<TextCustom size="small">
{item?.Profile?.businessField}
</TextCustom>
)}
</StackCustom>
</Grid.Col>
<Grid.Col
span={1}
style={{
justifyContent: "center",
alignItems: "flex-end",
}}
>
<Ionicons
name="chevron-forward"
size={ICON_SIZE_SMALL}
color={MainColor.placeholder}
/>
</Grid.Col>
</Grid>
</ClickableCustom>
</View>
);
const { ListEmptyComponent, ListFooterComponent } = createPaginationComponents({
loading,
refreshing,
listData,
searchQuery: search,
emptyMessage: "Tidak ada pengguna ditemukan",
emptySearchMessage: "Tidak ada hasil pencarian",
skeletonCount: 5,
skeletonHeight: 150,
loadingFooterText: "Memuat lebih banyak pengguna...",
isInitialLoad
});
return (
<>
<NewWrapper
headerComponent={renderHeader()}
listData={listData}
renderItem={renderItem}
onEndReached={loadMore}
refreshControl={
<RefreshControl
progressBackgroundColor={MainColor.yellow}
refreshing={refreshing}
onRefresh={onRefresh}
/>
}
ListFooterComponent={ListFooterComponent}
ListEmptyComponent={ListEmptyComponent}
/>
</>
);
// return (
// <>
// <ViewWrapper>
// <View style={{ padding: 16 }}>
// <TextCustom>{JSON.stringify(listData, null, 2)}</TextCustom>
// </View>
// </ViewWrapper>
// </>
// );
}

View File

@@ -5,12 +5,27 @@ export async function apiUser(id: string) {
return response.data;
}
export async function apiAllUser({ search }: { search: string }) {
const response = await apiConfig.get(`/mobile/user?search=${search}`);
return response.data;
export async function apiAllUser({
page,
search,
}: {
page?: string;
search?: string;
}) {
const pageQuery = page ? `?page=${page}` : "";
const searchQuery = search ? `&search=${search}` : "";
try {
const response = await apiConfig.get(
`/mobile/user${pageQuery}${searchQuery}`,
);
return response.data;
} catch (error) {
throw error;
}
}
export async function apiDeleteUser({id}:{id: string}) {
export async function apiDeleteUser({ id }: { id: string }) {
const response = await apiConfig.delete(`/mobile/user/${id}`);
return response.data;
}
@@ -37,12 +52,19 @@ export async function apiForumBlockUser({
}
}
export async function apiAcceptForumTerms({category, userId}:{category:"Forum" | "Event", userId: string}) {
export async function apiAcceptForumTerms({
category,
userId,
}: {
category: "Forum" | "Event";
userId: string;
}) {
try {
const response = await apiConfig.post(`/mobile/user/${userId}/terms-of-app?category=${category}`);
const response = await apiConfig.post(
`/mobile/user/${userId}/terms-of-app?category=${category}`,
);
return response.data;
} catch (error) {
throw error;
}
}