Compare commits
4 Commits
notificati
...
notificati
| Author | SHA1 | Date | |
|---|---|---|---|
| ed16f1b204 | |||
| d693550a1f | |||
| b3bfbc0f7e | |||
| 71e45d06cc |
179
QWEN.md
Normal file
179
QWEN.md
Normal 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
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
import { CenterCustom, TextCustom, ViewWrapper } from "@/components";
|
import { CenterCustom, TextCustom, ViewWrapper } from "@/components";
|
||||||
import API_STRORAGE from "@/constants/base-url-api-strorage";
|
import API_STRORAGE from "@/constants/base-url-api-strorage";
|
||||||
|
import { MainColor } from "@/constants/color-palet";
|
||||||
import { Image } from "expo-image";
|
import { Image } from "expo-image";
|
||||||
import { useLocalSearchParams } from "expo-router";
|
import { useLocalSearchParams } from "expo-router";
|
||||||
import React, { useState } from "react";
|
import React, { useState } from "react";
|
||||||
|
import { View } from "react-native";
|
||||||
|
|
||||||
export default function PreviewImage() {
|
export default function PreviewImage() {
|
||||||
const { id } = useLocalSearchParams();
|
const { id } = useLocalSearchParams();
|
||||||
@@ -11,18 +13,48 @@ export default function PreviewImage() {
|
|||||||
return (
|
return (
|
||||||
<ViewWrapper>
|
<ViewWrapper>
|
||||||
{id ? (
|
{id ? (
|
||||||
<Image
|
<View
|
||||||
onLoad={() => {
|
style={{
|
||||||
setIsLoading(false);
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
position: "relative",
|
||||||
}}
|
}}
|
||||||
source={
|
>
|
||||||
isLoading
|
{/* Main Image */}
|
||||||
? require("@/assets/images/loading.gif")
|
<Image
|
||||||
: API_STRORAGE.GET({ fileId: id as string })
|
onLoad={() => {
|
||||||
}
|
setIsLoading(false);
|
||||||
contentFit="contain"
|
}}
|
||||||
style={{ width: "100%", height: "100%" }}
|
source={API_STRORAGE.GET({ fileId: id as string })}
|
||||||
/>
|
contentFit="contain"
|
||||||
|
style={{ width: "100%", height: "100%" }}
|
||||||
|
// placeholder={require("@/assets/images/loading.gif")}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Custom Loader Overlay */}
|
||||||
|
{isLoading && (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
backgroundColor: MainColor.darkblue,
|
||||||
|
zIndex: 1,
|
||||||
|
opacity: 0.5,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
source={require("@/assets/images/loading.gif")}
|
||||||
|
contentFit="contain"
|
||||||
|
style={{ width: 60, height: 60 }}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
) : (
|
) : (
|
||||||
<CenterCustom>
|
<CenterCustom>
|
||||||
<TextCustom>File not found</TextCustom>
|
<TextCustom>File not found</TextCustom>
|
||||||
|
|||||||
@@ -26,7 +26,6 @@ export default function DonationBeranda() {
|
|||||||
const response = await apiDonationGetAll({
|
const response = await apiDonationGetAll({
|
||||||
category: "beranda"
|
category: "beranda"
|
||||||
});
|
});
|
||||||
console.log("[RES GET ALL]", JSON.stringify(response.data, null, 2));
|
|
||||||
|
|
||||||
setList(response.data);
|
setList(response.data);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import { Href, router, useFocusEffect } from "expo-router";
|
|||||||
import _ from "lodash";
|
import _ from "lodash";
|
||||||
import { useCallback, useState } from "react";
|
import { useCallback, useState } from "react";
|
||||||
import { View } from "react-native";
|
import { View } from "react-native";
|
||||||
|
import Toast from "react-native-toast-message";
|
||||||
|
|
||||||
export default function DonationMyDonation() {
|
export default function DonationMyDonation() {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
@@ -25,20 +26,25 @@ export default function DonationMyDonation() {
|
|||||||
useFocusEffect(
|
useFocusEffect(
|
||||||
useCallback(() => {
|
useCallback(() => {
|
||||||
onLoadData();
|
onLoadData();
|
||||||
}, [user?.id])
|
}, [user?.id]),
|
||||||
);
|
);
|
||||||
|
|
||||||
const onLoadData = async () => {
|
const onLoadData = async () => {
|
||||||
|
if (!user?.id) {
|
||||||
|
Toast.show({
|
||||||
|
type: "error",
|
||||||
|
text1: "Load data gagal, user tidak ditemukan",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setLoadList(true);
|
setLoadList(true);
|
||||||
const response = await apiDonationGetAll({
|
const response = await apiDonationGetAll({
|
||||||
category: "my-donation",
|
category: "my-donation",
|
||||||
authorId: user?.id,
|
authorId: user?.id,
|
||||||
});
|
});
|
||||||
console.log(
|
|
||||||
"[RES GET MY DONATION]",
|
|
||||||
JSON.stringify(response.data, null, 2)
|
|
||||||
);
|
|
||||||
|
|
||||||
setList(response.data);
|
setList(response.data);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -10,21 +10,32 @@ import {
|
|||||||
import { MainColor } from "@/constants/color-palet";
|
import { MainColor } from "@/constants/color-palet";
|
||||||
import { ICON_SIZE_SMALL } from "@/constants/constans-value";
|
import { ICON_SIZE_SMALL } from "@/constants/constans-value";
|
||||||
import { LOCAL_STORAGE_KEY } from "@/constants/local-storage-key";
|
import { LOCAL_STORAGE_KEY } from "@/constants/local-storage-key";
|
||||||
|
import { useAuth } from "@/hooks/use-auth";
|
||||||
import { formatCurrencyDisplay } from "@/utils/formatCurrencyDisplay";
|
import { formatCurrencyDisplay } from "@/utils/formatCurrencyDisplay";
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||||
import { router, useLocalSearchParams } from "expo-router";
|
import { router, useLocalSearchParams } from "expo-router";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import Toast from "react-native-toast-message";
|
||||||
|
|
||||||
export default function InvestmentInputDonation() {
|
export default function InvestmentInputDonation() {
|
||||||
|
const { user } = useAuth();
|
||||||
const { id } = useLocalSearchParams();
|
const { id } = useLocalSearchParams();
|
||||||
const [nominal, setNominal] = useState<number>(0);
|
const [nominal, setNominal] = useState<number>(0);
|
||||||
|
|
||||||
const handlerSubmit = async () => {
|
const handlerSubmit = async () => {
|
||||||
|
if (!user?.id) {
|
||||||
|
Toast.show({
|
||||||
|
type: "error",
|
||||||
|
text1: "User tidak ditemukan",
|
||||||
|
});
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
await AsyncStorage.setItem(
|
await AsyncStorage.setItem(
|
||||||
LOCAL_STORAGE_KEY.transactionDonation,
|
LOCAL_STORAGE_KEY.transactionDonation,
|
||||||
JSON.stringify({ nominal: nominal.toString() })
|
JSON.stringify({ nominal: nominal.toString() }),
|
||||||
);
|
);
|
||||||
router.replace(`/donation/${id}/select-bank`);
|
router.replace(`/donation/${id}/select-bank`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -1,258 +1,11 @@
|
|||||||
import {
|
import DetailForum from "@/screens/Forum/DetailForum";
|
||||||
ButtonCustom,
|
import DetailForum2 from "@/screens/Forum/DetailForum2";
|
||||||
DrawerCustom,
|
|
||||||
LoaderCustom,
|
|
||||||
Spacing,
|
|
||||||
TextAreaCustom,
|
|
||||||
TextCustom,
|
|
||||||
ViewWrapper,
|
|
||||||
} from "@/components";
|
|
||||||
import AlertWarning from "@/components/Alert/AlertWarning";
|
|
||||||
import { useAuth } from "@/hooks/use-auth";
|
|
||||||
import Forum_CommentarBoxSection from "@/screens/Forum/CommentarBoxSection";
|
|
||||||
import Forum_BoxDetailSection from "@/screens/Forum/DiscussionBoxSection";
|
|
||||||
import Forum_MenuDrawerBerandaSection from "@/screens/Forum/MenuDrawerSection.tsx/MenuBeranda";
|
|
||||||
import Forum_MenuDrawerCommentar from "@/screens/Forum/MenuDrawerSection.tsx/MenuCommentar";
|
|
||||||
import {
|
|
||||||
apiForumCreateComment,
|
|
||||||
apiForumGetComment,
|
|
||||||
apiForumGetOne,
|
|
||||||
apiForumUpdateStatus,
|
|
||||||
} from "@/service/api-client/api-forum";
|
|
||||||
import { TypeForum_CommentProps } from "@/types/type-forum";
|
|
||||||
import { isBadContent } from "@/utils/badWordsIndonesia";
|
|
||||||
import { useFocusEffect, useLocalSearchParams } from "expo-router";
|
|
||||||
import _ from "lodash";
|
|
||||||
import { useCallback, useEffect, useState } from "react";
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
export default function ForumDetail() {
|
export default function ForumDetail() {
|
||||||
const { id } = useLocalSearchParams();
|
|
||||||
const { user } = useAuth();
|
|
||||||
const [openDrawer, setOpenDrawer] = useState(false);
|
|
||||||
const [data, setData] = useState<any | null>(null);
|
|
||||||
const [listComment, setListComment] = useState<TypeForum_CommentProps[] | null>(null);
|
|
||||||
const [isLoadingComment, setLoadingComment] = useState(false);
|
|
||||||
|
|
||||||
// Status
|
|
||||||
const [status, setStatus] = useState("");
|
|
||||||
const [text, setText] = useState("");
|
|
||||||
const [authorId, setAuthorId] = useState("");
|
|
||||||
const [dataId, setDataId] = useState("");
|
|
||||||
|
|
||||||
// Comentar
|
|
||||||
const [openDrawerCommentar, setOpenDrawerCommentar] = useState(false);
|
|
||||||
const [commentId, setCommentId] = useState("");
|
|
||||||
const [commentAuthorId, setCommentAuthorId] = useState("");
|
|
||||||
|
|
||||||
useFocusEffect(
|
|
||||||
useCallback(() => {
|
|
||||||
onLoadData(id as string);
|
|
||||||
}, [id])
|
|
||||||
);
|
|
||||||
|
|
||||||
const onLoadData = async (id: string) => {
|
|
||||||
try {
|
|
||||||
const response = await apiForumGetOne({ id });
|
|
||||||
setData(response.data);
|
|
||||||
} catch (error) {
|
|
||||||
console.log("[ERROR]", error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
onLoadListComment(id as string);
|
|
||||||
}, [id]);
|
|
||||||
|
|
||||||
const onLoadListComment = async (id: string) => {
|
|
||||||
try {
|
|
||||||
const response = await apiForumGetComment({
|
|
||||||
id: id as string,
|
|
||||||
});
|
|
||||||
setListComment(response.data);
|
|
||||||
} catch (error) {
|
|
||||||
console.log("[ERROR]", error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Update Status
|
|
||||||
const handlerUpdateStatus = async (value: any) => {
|
|
||||||
try {
|
|
||||||
const response = await apiForumUpdateStatus({
|
|
||||||
id: id as string,
|
|
||||||
data: value,
|
|
||||||
});
|
|
||||||
if (response.success) {
|
|
||||||
setStatus(response.data);
|
|
||||||
setData({
|
|
||||||
...data,
|
|
||||||
ForumMaster_StatusPosting: {
|
|
||||||
status: response.data,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.log("[ERROR]", error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Create Commentar
|
|
||||||
const handlerCreateCommentar = async () => {
|
|
||||||
if (isBadContent(text)) {
|
|
||||||
AlertWarning({});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const newData = {
|
|
||||||
comment: text,
|
|
||||||
authorId: user?.id,
|
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
|
||||||
setLoadingComment(true);
|
|
||||||
const response = await apiForumCreateComment({
|
|
||||||
id: id as string,
|
|
||||||
data: newData,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.success) {
|
|
||||||
setText("");
|
|
||||||
const newComment = {
|
|
||||||
id: response.data.id,
|
|
||||||
isActive: response.data.isActive,
|
|
||||||
komentar: response.data.komentar,
|
|
||||||
createdAt: response.data.createdAt,
|
|
||||||
authorId: response.data.authorId,
|
|
||||||
Author: response.data.Author,
|
|
||||||
};
|
|
||||||
setListComment((prev) => [newComment, ...(prev || [])]);
|
|
||||||
setData({
|
|
||||||
...data,
|
|
||||||
count: data.count + 1,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.log("[ERROR]", error);
|
|
||||||
} finally {
|
|
||||||
setLoadingComment(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<ViewWrapper>
|
{/* <DetailForum />; */}
|
||||||
{!data && !listComment ? (
|
<DetailForum2 />
|
||||||
<LoaderCustom />
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
{/* Box Posting */}
|
|
||||||
<Forum_BoxDetailSection
|
|
||||||
data={data}
|
|
||||||
onSetData={() => {
|
|
||||||
setOpenDrawer(true);
|
|
||||||
setStatus(data.ForumMaster_StatusPosting?.status);
|
|
||||||
setAuthorId(data.Author?.id);
|
|
||||||
setDataId(data.id);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Area Commentar */}
|
|
||||||
{data?.ForumMaster_StatusPosting?.status === "Open" && (
|
|
||||||
<>
|
|
||||||
<TextAreaCustom
|
|
||||||
placeholder="Ketik diskusi anda..."
|
|
||||||
maxLength={1000}
|
|
||||||
showCount
|
|
||||||
value={text}
|
|
||||||
onChangeText={setText}
|
|
||||||
style={{
|
|
||||||
marginBottom: 0,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<ButtonCustom
|
|
||||||
isLoading={isLoadingComment}
|
|
||||||
style={{
|
|
||||||
alignSelf: "flex-end",
|
|
||||||
}}
|
|
||||||
onPress={() => {
|
|
||||||
handlerCreateCommentar();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Balas
|
|
||||||
</ButtonCustom>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
<Spacing height={40} />
|
|
||||||
|
|
||||||
{/* List Commentar */}
|
|
||||||
{_.isEmpty(listComment) ? (
|
|
||||||
<TextCustom align="center" color="gray" size={"small"}>
|
|
||||||
Tidak ada komentar
|
|
||||||
</TextCustom>
|
|
||||||
) : (
|
|
||||||
<TextCustom color="gray">Komentar :</TextCustom>
|
|
||||||
)}
|
|
||||||
<Spacing height={5} />
|
|
||||||
{listComment?.map((item: any, index: number) => (
|
|
||||||
<Forum_CommentarBoxSection
|
|
||||||
key={index}
|
|
||||||
data={item}
|
|
||||||
onSetData={(value) => {
|
|
||||||
setCommentId(value.setCommentId);
|
|
||||||
setOpenDrawerCommentar(value.setOpenDrawer);
|
|
||||||
setCommentAuthorId(value.setCommentAuthorId);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</ViewWrapper>
|
|
||||||
|
|
||||||
{/* Posting Drawer */}
|
|
||||||
<DrawerCustom
|
|
||||||
height={"auto"}
|
|
||||||
isVisible={openDrawer}
|
|
||||||
closeDrawer={() => setOpenDrawer(false)}
|
|
||||||
>
|
|
||||||
<Forum_MenuDrawerBerandaSection
|
|
||||||
id={dataId}
|
|
||||||
authorUsername={data?.Author?.username as string}
|
|
||||||
status={status}
|
|
||||||
setIsDrawerOpen={() => {
|
|
||||||
setOpenDrawer(false);
|
|
||||||
}}
|
|
||||||
authorId={authorId}
|
|
||||||
handlerUpdateStatus={(value: any) => {
|
|
||||||
handlerUpdateStatus(value);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</DrawerCustom>
|
|
||||||
|
|
||||||
{/* Commentar Drawer */}
|
|
||||||
<DrawerCustom
|
|
||||||
height={"auto"}
|
|
||||||
isVisible={openDrawerCommentar}
|
|
||||||
closeDrawer={() => setOpenDrawerCommentar(false)}
|
|
||||||
>
|
|
||||||
<Forum_MenuDrawerCommentar
|
|
||||||
id={commentId as string}
|
|
||||||
commentId={commentId}
|
|
||||||
commentAuthorId={commentAuthorId}
|
|
||||||
setIsDrawerOpen={() => {
|
|
||||||
setOpenDrawerCommentar(false);
|
|
||||||
}}
|
|
||||||
listComment={listComment}
|
|
||||||
setListComment={setListComment}
|
|
||||||
countComment={data?.count}
|
|
||||||
setCountComment={(val: any) => {
|
|
||||||
setData((prev: any) => ({
|
|
||||||
...prev,
|
|
||||||
count: val,
|
|
||||||
}));
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</DrawerCustom>
|
|
||||||
</>
|
</>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
/* eslint-disable react-hooks/exhaustive-deps */
|
/* eslint-disable react-hooks/exhaustive-deps */
|
||||||
import Forum_ViewBeranda from "@/screens/Forum/ViewBeranda";
|
import Forum_ViewBeranda from "@/screens/Forum/ViewBeranda";
|
||||||
import Forum_ViewBeranda2 from "@/screens/Forum/ViewBeranda2";
|
import Forum_ViewBeranda2 from "@/screens/Forum/ViewBeranda2";
|
||||||
|
import Forum_ViewBeranda3 from "@/screens/Forum/ViewBeranda3";
|
||||||
|
|
||||||
export default function Forum() {
|
export default function Forum() {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* <Forum_ViewBeranda /> */}
|
{/* <Forum_ViewBeranda /> */}
|
||||||
<Forum_ViewBeranda2 />
|
{/* <Forum_ViewBeranda2 /> */}
|
||||||
|
<Forum_ViewBeranda3 />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,115 +1,11 @@
|
|||||||
import {
|
import UserSearchMainView from "@/screens/UserSeach/MainView";
|
||||||
AvatarComp,
|
import UserSearchMainView_V2 from "@/screens/UserSeach/MainView_V2";
|
||||||
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";
|
|
||||||
|
|
||||||
export default function UserSearch() {
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<ViewWrapper
|
{/* <UserSearchMainView /> */}
|
||||||
headerComponent={
|
<UserSearchMainView_V2 />
|
||||||
<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>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,11 @@ import {
|
|||||||
} from "@/components";
|
} from "@/components";
|
||||||
import AdminBackButtonAntTitle from "@/components/_ShareComponent/Admin/BackButtonAntTitle";
|
import AdminBackButtonAntTitle from "@/components/_ShareComponent/Admin/BackButtonAntTitle";
|
||||||
import DIRECTORY_ID from "@/constants/directory-id";
|
import DIRECTORY_ID from "@/constants/directory-id";
|
||||||
import { apiAdminDonationDetailById, apiAdminDonationDisbursementOfFundsCreated } from "@/service/api-admin/api-admin-donation";
|
import { useAuth } from "@/hooks/use-auth";
|
||||||
|
import {
|
||||||
|
apiAdminDonationDetailById,
|
||||||
|
apiAdminDonationDisbursementOfFundsCreated,
|
||||||
|
} from "@/service/api-admin/api-admin-donation";
|
||||||
import { uploadFileService } from "@/service/upload-service";
|
import { uploadFileService } from "@/service/upload-service";
|
||||||
import { formatCurrencyDisplay } from "@/utils/formatCurrencyDisplay";
|
import { formatCurrencyDisplay } from "@/utils/formatCurrencyDisplay";
|
||||||
import pickFile from "@/utils/pickFile";
|
import pickFile from "@/utils/pickFile";
|
||||||
@@ -25,7 +29,7 @@ import Toast from "react-native-toast-message";
|
|||||||
|
|
||||||
export default function AdminDonationDisbursementOfFunds() {
|
export default function AdminDonationDisbursementOfFunds() {
|
||||||
const { id } = useLocalSearchParams();
|
const { id } = useLocalSearchParams();
|
||||||
|
const { user } = useAuth();
|
||||||
const [data, setData] = React.useState<any | null>(null);
|
const [data, setData] = React.useState<any | null>(null);
|
||||||
const [isLoading, setIsLoading] = React.useState(false);
|
const [isLoading, setIsLoading] = React.useState(false);
|
||||||
|
|
||||||
@@ -40,7 +44,7 @@ export default function AdminDonationDisbursementOfFunds() {
|
|||||||
useFocusEffect(
|
useFocusEffect(
|
||||||
React.useCallback(() => {
|
React.useCallback(() => {
|
||||||
onLoadData();
|
onLoadData();
|
||||||
}, [id])
|
}, [id]),
|
||||||
);
|
);
|
||||||
|
|
||||||
const onLoadData = async () => {
|
const onLoadData = async () => {
|
||||||
@@ -94,6 +98,7 @@ export default function AdminDonationDisbursementOfFunds() {
|
|||||||
|
|
||||||
const newData = {
|
const newData = {
|
||||||
...value,
|
...value,
|
||||||
|
authorId: user?.id,
|
||||||
imageId: imageId,
|
imageId: imageId,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -10,14 +10,10 @@ import {
|
|||||||
import AdminBackButtonAntTitle from "@/components/_ShareComponent/Admin/BackButtonAntTitle";
|
import AdminBackButtonAntTitle from "@/components/_ShareComponent/Admin/BackButtonAntTitle";
|
||||||
import GridTwoView from "@/components/_ShareComponent/GridTwoView";
|
import GridTwoView from "@/components/_ShareComponent/GridTwoView";
|
||||||
import { useAuth } from "@/hooks/use-auth";
|
import { useAuth } from "@/hooks/use-auth";
|
||||||
import { routeUser } from "@/lib/routeApp";
|
|
||||||
import {
|
import {
|
||||||
apiAdminUserAccessGetById,
|
apiAdminUserAccessGetById,
|
||||||
apiAdminUserAccessUpdateStatus,
|
apiAdminUserAccessUpdateStatus,
|
||||||
} from "@/service/api-admin/api-admin-user-access";
|
} from "@/service/api-admin/api-admin-user-access";
|
||||||
import {
|
|
||||||
apiNotificationsSendById
|
|
||||||
} from "@/service/api-notifications";
|
|
||||||
import { router, useFocusEffect, useLocalSearchParams } from "expo-router";
|
import { router, useFocusEffect, useLocalSearchParams } from "expo-router";
|
||||||
import { useCallback, useState } from "react";
|
import { useCallback, useState } from "react";
|
||||||
import Toast from "react-native-toast-message";
|
import Toast from "react-native-toast-message";
|
||||||
@@ -70,20 +66,6 @@ export default function AdminUserAccessDetail() {
|
|||||||
text1: "Update aktifasi berhasil ",
|
text1: "Update aktifasi berhasil ",
|
||||||
});
|
});
|
||||||
|
|
||||||
if (data.active === false) {
|
|
||||||
await apiNotificationsSendById({
|
|
||||||
data: {
|
|
||||||
title: "Akun anda telah diaktifkan",
|
|
||||||
body: "Selamat menjelajahi HIConnect",
|
|
||||||
userLoginId: user?.id || "",
|
|
||||||
kategoriApp: "OTHER",
|
|
||||||
type: "announcement",
|
|
||||||
deepLink: routeUser.home,
|
|
||||||
},
|
|
||||||
id: id as string,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
router.back();
|
router.back();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log("[ERROR UPDATE STATUS]", error);
|
console.log("[ERROR UPDATE STATUS]", error);
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ export default function NotFoundScreen() {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
options={{ headerShown: false, headerLeft: () => <BackButton /> }}
|
options={{ headerShown: true, title: "", headerLeft: () => <BackButton /> }}
|
||||||
/>
|
/>
|
||||||
<ViewWrapper>
|
<ViewWrapper>
|
||||||
<StackCustom
|
<StackCustom
|
||||||
@@ -17,7 +17,7 @@ export default function NotFoundScreen() {
|
|||||||
404
|
404
|
||||||
</TextCustom>
|
</TextCustom>
|
||||||
<TextCustom size="large" bold>
|
<TextCustom size="large" bold>
|
||||||
Sorry, File Not Found
|
Sorry, Page Not Found
|
||||||
</TextCustom>
|
</TextCustom>
|
||||||
</StackCustom>
|
</StackCustom>
|
||||||
</ViewWrapper>
|
</ViewWrapper>
|
||||||
|
|||||||
@@ -30,7 +30,6 @@ export default function AvatarComp({
|
|||||||
href = `/(application)/(image)/preview-image/${fileId}`,
|
href = `/(application)/(image)/preview-image/${fileId}`,
|
||||||
}: AvatarCompProps) {
|
}: AvatarCompProps) {
|
||||||
const dimension = sizeMap[size];
|
const dimension = sizeMap[size];
|
||||||
|
|
||||||
const avatarImage = () => {
|
const avatarImage = () => {
|
||||||
return (
|
return (
|
||||||
<Avatar.Image
|
<Avatar.Image
|
||||||
@@ -52,8 +51,9 @@ export default function AvatarComp({
|
|||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
activeOpacity={0.9}
|
activeOpacity={0.9}
|
||||||
onPress={
|
onPress={
|
||||||
href && fileId ? () => router.navigate(href as any) : onPress
|
href || fileId ? () => router.navigate(href as any) : onPress
|
||||||
}
|
}
|
||||||
|
disabled={!fileId}
|
||||||
>
|
>
|
||||||
{avatarImage()}
|
{avatarImage()}
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ export {
|
|||||||
PADDING_SMALL,
|
PADDING_SMALL,
|
||||||
PADDING_MEDIUM,
|
PADDING_MEDIUM,
|
||||||
PADDING_LARGE,
|
PADDING_LARGE,
|
||||||
|
PAGINATION_DEFAULT_TAKE
|
||||||
};
|
};
|
||||||
|
|
||||||
// OS Height
|
// OS Height
|
||||||
@@ -51,3 +52,5 @@ const PADDING_SMALL = 12
|
|||||||
const PADDING_MEDIUM = 16
|
const PADDING_MEDIUM = 16
|
||||||
const PADDING_LARGE = 20
|
const PADDING_LARGE = 20
|
||||||
|
|
||||||
|
// Pagination
|
||||||
|
const PAGINATION_DEFAULT_TAKE = 10;
|
||||||
|
|||||||
@@ -30,7 +30,10 @@ type AuthContextType = {
|
|||||||
termsOfServiceAccepted: boolean;
|
termsOfServiceAccepted: boolean;
|
||||||
}) => Promise<void>;
|
}) => Promise<void>;
|
||||||
userData: (token: string) => Promise<any>;
|
userData: (token: string) => Promise<any>;
|
||||||
acceptedTerms: (nomor: string, onSetModalVisible: (visible: boolean) => void) => Promise<any>;
|
acceptedTerms: (
|
||||||
|
nomor: string,
|
||||||
|
onSetModalVisible: (visible: boolean) => void,
|
||||||
|
) => Promise<any>;
|
||||||
};
|
};
|
||||||
|
|
||||||
// --- Create Context ---
|
// --- Create Context ---
|
||||||
@@ -80,34 +83,12 @@ export const AuthProvider = ({ children }: { children: React.ReactNode }) => {
|
|||||||
console.log("[RESPONSE AUTH]", JSON.stringify(response, null, 2));
|
console.log("[RESPONSE AUTH]", JSON.stringify(response, null, 2));
|
||||||
|
|
||||||
if (response.success && response.isAcceptTerms) {
|
if (response.success && response.isAcceptTerms) {
|
||||||
|
|
||||||
await AsyncStorage.setItem("kode_otp", response.kodeId);
|
await AsyncStorage.setItem("kode_otp", response.kodeId);
|
||||||
router.push(`/verification?nomor=${nomor}`);
|
router.push(`/verification?nomor=${nomor}`);
|
||||||
return true;
|
return true;
|
||||||
} else {
|
} else {
|
||||||
// router.push(`/eula?nomor=${nomor}`);
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// if (response.success) {
|
|
||||||
// if (response.isAcceptTerms) {
|
|
||||||
// Toast.show({
|
|
||||||
// type: "success",
|
|
||||||
// text1: "Sukses",
|
|
||||||
// text2: "Kode OTP berhasil dikirim",
|
|
||||||
// });
|
|
||||||
|
|
||||||
// await AsyncStorage.setItem("kode_otp", response.kodeId);
|
|
||||||
// router.push(`/verification?nomor=${nomor}`);
|
|
||||||
// return false
|
|
||||||
// } else {
|
|
||||||
// // router.push(`/eula?nomor=${nomor}`);
|
|
||||||
// return true
|
|
||||||
// }
|
|
||||||
// } else {
|
|
||||||
// router.push(`/eula?nomor=${nomor}`);
|
|
||||||
// return true;
|
|
||||||
// }
|
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
throw new Error(error.response?.data?.message || "Gagal kirim OTP");
|
throw new Error(error.response?.data?.message || "Gagal kirim OTP");
|
||||||
} finally {
|
} finally {
|
||||||
@@ -266,29 +247,24 @@ export const AuthProvider = ({ children }: { children: React.ReactNode }) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// --- 6. Accept Terms ---
|
// --- 6. Accept Terms ---
|
||||||
const acceptedTerms = async (nomor: string, onSetModalVisible: (visible: boolean) => void) => {
|
const acceptedTerms = async (
|
||||||
|
nomor: string,
|
||||||
|
onSetModalVisible: (visible: boolean) => void,
|
||||||
|
) => {
|
||||||
try {
|
try {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
const response = await apiUpdatedTermCondition({ nomor: nomor });
|
const response = await apiUpdatedTermCondition({ nomor: nomor });
|
||||||
|
|
||||||
if (response.success) {
|
if (response.success) {
|
||||||
router.replace(`/verification?nomor=${nomor}`);
|
return `/verification?nomor=${nomor}`;
|
||||||
} else {
|
} else {
|
||||||
if (response.status === 404) {
|
return `/register?nomor=${nomor}`;
|
||||||
router.replace(`/register?nomor=${nomor}`);
|
|
||||||
} else {
|
|
||||||
Toast.show({
|
|
||||||
type: "error",
|
|
||||||
text1: "Error",
|
|
||||||
text2: response.message,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log("Error accept terms", error);
|
console.log("Error accept terms", error);
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
// onSetModalVisible(false);
|
onSetModalVisible(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
14
docs/prompt-for-qwen-code.md
Normal file
14
docs/prompt-for-qwen-code.md
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
<!-- Start Penerapan Pagination -->
|
||||||
|
Terapkan pagination pada file: screens/Forum/DetailForum.tsx
|
||||||
|
Component yang digunakan: hooks/use-pagination.tsx dan helpers/paginationHelpers.tsx
|
||||||
|
|
||||||
|
Perbaiki fetch pada file: service/api-client/api-forum.ts
|
||||||
|
Jika tidak ada props page maka tambahkan props page dan default page: "1"
|
||||||
|
|
||||||
|
Gunakan bahasa indonesia pada cli agar saya mudah membacanya.
|
||||||
|
|
||||||
|
<!-- End Penerapan Pagination -->
|
||||||
|
|
||||||
|
<!-- Start Penerapan NewWrapper -->
|
||||||
|
Terapkan NewWrapper pada file: screens/Forum/DetailForum.tsx
|
||||||
|
Component yang digunakan: components/_ShareComponent/NewWrapper.tsx , karena ini adalah halaman detail saya ingin anda fokus pada props pada NewWrapper. Seperti
|
||||||
517
helpers/PaginationGuide.md
Normal file
517
helpers/PaginationGuide.md
Normal 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!
|
||||||
280
helpers/paginationHelpers.tsx
Normal file
280
helpers/paginationHelpers.tsx
Normal 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
184
hooks/use-pagination.tsx
Normal 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
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -7,13 +7,21 @@ import {
|
|||||||
StyleSheet,
|
StyleSheet,
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
import { useState, useRef } from "react";
|
import { useState, useRef } from "react";
|
||||||
import { useLocalSearchParams, useRouter } from "expo-router";
|
import { router, useLocalSearchParams, useRouter } from "expo-router";
|
||||||
import { SafeAreaView } from "react-native-safe-area-context";
|
import { SafeAreaView } from "react-native-safe-area-context";
|
||||||
import { AccentColor, MainColor } from "@/constants/color-palet";
|
import { AccentColor, MainColor } from "@/constants/color-palet";
|
||||||
import { useAuth } from "@/hooks/use-auth";
|
import { useAuth } from "@/hooks/use-auth";
|
||||||
|
import Toast from "react-native-toast-message";
|
||||||
|
|
||||||
|
export default function EULASection({
|
||||||
export default function EULASection({ nomor, onSetModalVisible }: { nomor: string, onSetModalVisible: (visible: boolean) => void }) {
|
nomor,
|
||||||
|
onSetModalVisible,
|
||||||
|
setLoadingTerm,
|
||||||
|
}: {
|
||||||
|
nomor: string;
|
||||||
|
onSetModalVisible: (visible: boolean) => void;
|
||||||
|
setLoadingTerm: (loading: boolean) => void;
|
||||||
|
}) {
|
||||||
const { acceptedTerms } = useAuth();
|
const { acceptedTerms } = useAuth();
|
||||||
const [isLoading, setIsLoading] = useState<boolean>(false);
|
const [isLoading, setIsLoading] = useState<boolean>(false);
|
||||||
const [isAtBottom, setIsAtBottom] = useState(false);
|
const [isAtBottom, setIsAtBottom] = useState(false);
|
||||||
@@ -35,12 +43,26 @@ export default function EULASection({ nomor, onSetModalVisible }: { nomor: strin
|
|||||||
if (!isAtBottom) return;
|
if (!isAtBottom) return;
|
||||||
|
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
await acceptedTerms(nomor as string, onSetModalVisible);
|
const responseAccept = await acceptedTerms(
|
||||||
|
nomor as string,
|
||||||
|
onSetModalVisible,
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log("Accept terms", responseAccept);
|
||||||
|
setLoadingTerm(true);
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
router.replace(responseAccept);
|
||||||
|
}, 500);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log("Error accept terms", error);
|
console.log("Error accept terms", error);
|
||||||
|
Toast.show({
|
||||||
|
type: "error",
|
||||||
|
text1: "Error",
|
||||||
|
text2: "Terjadi kesalahan saat menerima syarat dan ketentuan",
|
||||||
|
});
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,18 +1,16 @@
|
|||||||
import { NewWrapper, TextCustom } from "@/components";
|
import { NewWrapper } from "@/components";
|
||||||
import ButtonCustom from "@/components/Button/ButtonCustom";
|
import ButtonCustom from "@/components/Button/ButtonCustom";
|
||||||
import ModalReactNative from "@/components/Modal/ModalReactNative";
|
import ModalReactNative from "@/components/Modal/ModalReactNative";
|
||||||
import Spacing from "@/components/_ShareComponent/Spacing";
|
import Spacing from "@/components/_ShareComponent/Spacing";
|
||||||
import ViewWrapper from "@/components/_ShareComponent/ViewWrapper";
|
|
||||||
import { MainColor } from "@/constants/color-palet";
|
import { MainColor } from "@/constants/color-palet";
|
||||||
import { useAuth } from "@/hooks/use-auth";
|
import { useAuth } from "@/hooks/use-auth";
|
||||||
import { apiVersion, BASE_URL } from "@/service/api-config";
|
import { apiVersion, BASE_URL } from "@/service/api-config";
|
||||||
import { GStyles } from "@/styles/global-styles";
|
import { GStyles } from "@/styles/global-styles";
|
||||||
import { openBrowser } from "@/utils/openBrower";
|
import { openBrowser } from "@/utils/openBrower";
|
||||||
import versionBadge from "@/utils/viersionBadge";
|
import versionBadge from "@/utils/viersionBadge";
|
||||||
import VersionBadge from "@/utils/viersionBadge";
|
|
||||||
import { Redirect } from "expo-router";
|
import { Redirect } from "expo-router";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { Modal, RefreshControl, Text, View } from "react-native";
|
import { RefreshControl, Text, View } from "react-native";
|
||||||
import PhoneInput, { ICountry } from "react-native-international-phone-number";
|
import PhoneInput, { ICountry } from "react-native-international-phone-number";
|
||||||
import Toast from "react-native-toast-message";
|
import Toast from "react-native-toast-message";
|
||||||
import EULASection from "./EULASection";
|
import EULASection from "./EULASection";
|
||||||
@@ -26,6 +24,7 @@ export default function LoginView() {
|
|||||||
const [refreshing, setRefreshing] = useState<boolean>(false);
|
const [refreshing, setRefreshing] = useState<boolean>(false);
|
||||||
const [modalVisible, setModalVisible] = useState(false);
|
const [modalVisible, setModalVisible] = useState(false);
|
||||||
const [numberToEULA, setNumberToEULA] = useState<string>("");
|
const [numberToEULA, setNumberToEULA] = useState<string>("");
|
||||||
|
const [loadingTerm, setLoadingTerm] = useState<boolean>(false);
|
||||||
|
|
||||||
const { loginWithNomor, token, isAdmin, isUserActive } = useAuth();
|
const { loginWithNomor, token, isAdmin, isUserActive } = useAuth();
|
||||||
|
|
||||||
@@ -90,7 +89,6 @@ export default function LoginView() {
|
|||||||
let fixNumber = inputValue.replace(/\s+/g, "").replace(/^0+/, "");
|
let fixNumber = inputValue.replace(/\s+/g, "").replace(/^0+/, "");
|
||||||
|
|
||||||
const realNumber = callingCode + fixNumber;
|
const realNumber = callingCode + fixNumber;
|
||||||
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -137,7 +135,6 @@ export default function LoginView() {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<View style={GStyles.authContainer}>
|
<View style={GStyles.authContainer}>
|
||||||
|
|
||||||
<View>
|
<View>
|
||||||
<View style={GStyles.authContainerTitle}>
|
<View style={GStyles.authContainerTitle}>
|
||||||
<Text style={GStyles.authSubTitle}>WELCOME TO</Text>
|
<Text style={GStyles.authSubTitle}>WELCOME TO</Text>
|
||||||
@@ -172,21 +169,15 @@ export default function LoginView() {
|
|||||||
|
|
||||||
<Spacing />
|
<Spacing />
|
||||||
|
|
||||||
<ButtonCustom onPress={handleLogin} isLoading={loading}>
|
<ButtonCustom
|
||||||
|
onPress={handleLogin}
|
||||||
|
disabled={loadingTerm}
|
||||||
|
isLoading={loading || loadingTerm}
|
||||||
|
>
|
||||||
Login
|
Login
|
||||||
</ButtonCustom>
|
</ButtonCustom>
|
||||||
<Spacing height={50} />
|
<Spacing height={50} />
|
||||||
|
|
||||||
{/* <ButtonCustom
|
|
||||||
onPress={() => {
|
|
||||||
setModalVisible(true);
|
|
||||||
console.log("Show modal", modalVisible);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Show Modal
|
|
||||||
</ButtonCustom> */}
|
|
||||||
{/* <CheckboxCustom value={term} onChange={() => setTerm(!term)} /> */}
|
|
||||||
|
|
||||||
<Text
|
<Text
|
||||||
style={{ ...GStyles.textLabel, textAlign: "center", fontSize: 12 }}
|
style={{ ...GStyles.textLabel, textAlign: "center", fontSize: 12 }}
|
||||||
>
|
>
|
||||||
@@ -208,7 +199,11 @@ export default function LoginView() {
|
|||||||
</View>
|
</View>
|
||||||
|
|
||||||
<ModalReactNative isVisible={modalVisible}>
|
<ModalReactNative isVisible={modalVisible}>
|
||||||
<EULASection nomor={numberToEULA || ""} onSetModalVisible={setModalVisible} />
|
<EULASection
|
||||||
|
nomor={numberToEULA || ""}
|
||||||
|
onSetModalVisible={setModalVisible}
|
||||||
|
setLoadingTerm={setLoadingTerm}
|
||||||
|
/>
|
||||||
</ModalReactNative>
|
</ModalReactNative>
|
||||||
</NewWrapper>
|
</NewWrapper>
|
||||||
);
|
);
|
||||||
|
|||||||
276
screens/Forum/DetailForum.tsx
Normal file
276
screens/Forum/DetailForum.tsx
Normal file
@@ -0,0 +1,276 @@
|
|||||||
|
import {
|
||||||
|
BoxButtonOnFooter,
|
||||||
|
ButtonCustom,
|
||||||
|
DrawerCustom,
|
||||||
|
LoaderCustom,
|
||||||
|
NewWrapper,
|
||||||
|
Spacing,
|
||||||
|
TextAreaCustom,
|
||||||
|
TextCustom,
|
||||||
|
TextInputCustom,
|
||||||
|
ViewWrapper,
|
||||||
|
} from "@/components";
|
||||||
|
import CustomSkeleton from "@/components/_ShareComponent/SkeletonCustom";
|
||||||
|
import AlertWarning from "@/components/Alert/AlertWarning";
|
||||||
|
import { useAuth } from "@/hooks/use-auth";
|
||||||
|
import Forum_CommentarBoxSection from "@/screens/Forum/CommentarBoxSection";
|
||||||
|
import Forum_BoxDetailSection from "@/screens/Forum/DiscussionBoxSection";
|
||||||
|
import Forum_MenuDrawerBerandaSection from "@/screens/Forum/MenuDrawerSection.tsx/MenuBeranda";
|
||||||
|
import Forum_MenuDrawerCommentar from "@/screens/Forum/MenuDrawerSection.tsx/MenuCommentar";
|
||||||
|
import {
|
||||||
|
apiForumCreateComment,
|
||||||
|
apiForumGetComment,
|
||||||
|
apiForumGetOne,
|
||||||
|
apiForumUpdateStatus,
|
||||||
|
} from "@/service/api-client/api-forum";
|
||||||
|
import { TypeForum_CommentProps } from "@/types/type-forum";
|
||||||
|
import { isBadContent } from "@/utils/badWordsIndonesia";
|
||||||
|
import { useFocusEffect, useLocalSearchParams } from "expo-router";
|
||||||
|
import _ from "lodash";
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
|
||||||
|
export default function DetailForum() {
|
||||||
|
const { id } = useLocalSearchParams();
|
||||||
|
const { user } = useAuth();
|
||||||
|
const [openDrawer, setOpenDrawer] = useState(false);
|
||||||
|
const [data, setData] = useState<any | null>(null);
|
||||||
|
const [listComment, setListComment] = useState<
|
||||||
|
TypeForum_CommentProps[] | null
|
||||||
|
>(null);
|
||||||
|
const [isLoadingComment, setLoadingComment] = useState(false);
|
||||||
|
|
||||||
|
// Status
|
||||||
|
const [status, setStatus] = useState("");
|
||||||
|
const [text, setText] = useState("");
|
||||||
|
const [authorId, setAuthorId] = useState("");
|
||||||
|
const [dataId, setDataId] = useState("");
|
||||||
|
|
||||||
|
// Comentar
|
||||||
|
const [openDrawerCommentar, setOpenDrawerCommentar] = useState(false);
|
||||||
|
const [commentId, setCommentId] = useState("");
|
||||||
|
const [commentAuthorId, setCommentAuthorId] = useState("");
|
||||||
|
|
||||||
|
useFocusEffect(
|
||||||
|
useCallback(() => {
|
||||||
|
setTimeout(() => {
|
||||||
|
onLoadData(id as string);
|
||||||
|
}, 3000);
|
||||||
|
}, [id]),
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setTimeout(() => {
|
||||||
|
onLoadListComment(id as string);
|
||||||
|
}, 3000);
|
||||||
|
}, [id]);
|
||||||
|
|
||||||
|
const onLoadData = async (id: string) => {
|
||||||
|
try {
|
||||||
|
const response = await apiForumGetOne({ id });
|
||||||
|
setData(response.data);
|
||||||
|
} catch (error) {
|
||||||
|
console.log("[ERROR]", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onLoadListComment = async (id: string) => {
|
||||||
|
try {
|
||||||
|
const response = await apiForumGetComment({
|
||||||
|
id: id as string,
|
||||||
|
});
|
||||||
|
setListComment(response.data);
|
||||||
|
} catch (error) {
|
||||||
|
console.log("[ERROR]", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Update Status
|
||||||
|
const handlerUpdateStatus = async (value: any) => {
|
||||||
|
try {
|
||||||
|
const response = await apiForumUpdateStatus({
|
||||||
|
id: id as string,
|
||||||
|
data: value,
|
||||||
|
});
|
||||||
|
if (response.success) {
|
||||||
|
setStatus(response.data);
|
||||||
|
setData({
|
||||||
|
...data,
|
||||||
|
ForumMaster_StatusPosting: {
|
||||||
|
status: response.data,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.log("[ERROR]", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create Commentar
|
||||||
|
const handlerCreateCommentar = async () => {
|
||||||
|
if (isBadContent(text)) {
|
||||||
|
AlertWarning({});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const newData = {
|
||||||
|
comment: text,
|
||||||
|
authorId: user?.id,
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
setLoadingComment(true);
|
||||||
|
const response = await apiForumCreateComment({
|
||||||
|
id: id as string,
|
||||||
|
data: newData,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.success) {
|
||||||
|
setText("");
|
||||||
|
const newComment = {
|
||||||
|
id: response.data.id,
|
||||||
|
isActive: response.data.isActive,
|
||||||
|
komentar: response.data.komentar,
|
||||||
|
createdAt: response.data.createdAt,
|
||||||
|
authorId: response.data.authorId,
|
||||||
|
Author: response.data.Author,
|
||||||
|
};
|
||||||
|
setListComment((prev) => [newComment, ...(prev || [])]);
|
||||||
|
setData({
|
||||||
|
...data,
|
||||||
|
count: data.count + 1,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.log("[ERROR]", error);
|
||||||
|
} finally {
|
||||||
|
setLoadingComment(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const headerComponent = () =>
|
||||||
|
// Box Posting
|
||||||
|
!data && !listComment ? (
|
||||||
|
<CustomSkeleton height={200} />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Forum_BoxDetailSection
|
||||||
|
data={data}
|
||||||
|
onSetData={() => {
|
||||||
|
setOpenDrawer(true);
|
||||||
|
setStatus(data.ForumMaster_StatusPosting?.status);
|
||||||
|
setAuthorId(data.Author?.id);
|
||||||
|
setDataId(data.id);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{data?.ForumMaster_StatusPosting?.status === "Open" && (
|
||||||
|
<>
|
||||||
|
<TextAreaCustom
|
||||||
|
placeholder="Ketik diskusi anda..."
|
||||||
|
maxLength={1000}
|
||||||
|
showCount
|
||||||
|
value={text}
|
||||||
|
onChangeText={setText}
|
||||||
|
style={{
|
||||||
|
marginBottom: 0,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<ButtonCustom
|
||||||
|
isLoading={isLoadingComment}
|
||||||
|
style={{
|
||||||
|
alignSelf: "flex-end",
|
||||||
|
}}
|
||||||
|
onPress={() => {
|
||||||
|
handlerCreateCommentar();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Balas
|
||||||
|
</ButtonCustom>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<NewWrapper
|
||||||
|
// headerComponent={headerComponent()}
|
||||||
|
>
|
||||||
|
{!data && !listComment ? (
|
||||||
|
<LoaderCustom />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{/* Area Commentar */}
|
||||||
|
{headerComponent()}
|
||||||
|
<Spacing height={40} />
|
||||||
|
{/* List Commentar */}
|
||||||
|
{_.isEmpty(listComment) ? (
|
||||||
|
<TextCustom align="center" color="gray" size={"small"}>
|
||||||
|
Tidak ada komentar
|
||||||
|
</TextCustom>
|
||||||
|
) : (
|
||||||
|
<TextCustom color="gray">Komentar :</TextCustom>
|
||||||
|
)}
|
||||||
|
<Spacing height={5} />
|
||||||
|
{listComment?.map((item: any, index: number) => (
|
||||||
|
<Forum_CommentarBoxSection
|
||||||
|
key={index}
|
||||||
|
data={item}
|
||||||
|
onSetData={(value) => {
|
||||||
|
setCommentId(value.setCommentId);
|
||||||
|
setOpenDrawerCommentar(value.setOpenDrawer);
|
||||||
|
setCommentAuthorId(value.setCommentAuthorId);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</NewWrapper>
|
||||||
|
|
||||||
|
{/* Posting Drawer */}
|
||||||
|
<DrawerCustom
|
||||||
|
height={"auto"}
|
||||||
|
isVisible={openDrawer}
|
||||||
|
closeDrawer={() => setOpenDrawer(false)}
|
||||||
|
>
|
||||||
|
<Forum_MenuDrawerBerandaSection
|
||||||
|
id={dataId}
|
||||||
|
authorUsername={data?.Author?.username as string}
|
||||||
|
status={status}
|
||||||
|
setIsDrawerOpen={() => {
|
||||||
|
setOpenDrawer(false);
|
||||||
|
}}
|
||||||
|
authorId={authorId}
|
||||||
|
handlerUpdateStatus={(value: any) => {
|
||||||
|
handlerUpdateStatus(value);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</DrawerCustom>
|
||||||
|
|
||||||
|
{/* Commentar Drawer */}
|
||||||
|
<DrawerCustom
|
||||||
|
height={"auto"}
|
||||||
|
isVisible={openDrawerCommentar}
|
||||||
|
closeDrawer={() => setOpenDrawerCommentar(false)}
|
||||||
|
>
|
||||||
|
<Forum_MenuDrawerCommentar
|
||||||
|
id={commentId as string}
|
||||||
|
commentId={commentId}
|
||||||
|
commentAuthorId={commentAuthorId}
|
||||||
|
setIsDrawerOpen={() => {
|
||||||
|
setOpenDrawerCommentar(false);
|
||||||
|
}}
|
||||||
|
listComment={listComment}
|
||||||
|
setListComment={setListComment}
|
||||||
|
countComment={data?.count}
|
||||||
|
setCountComment={(val: any) => {
|
||||||
|
setData((prev: any) => ({
|
||||||
|
...prev,
|
||||||
|
count: val,
|
||||||
|
}));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</DrawerCustom>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
306
screens/Forum/DetailForum2.tsx
Normal file
306
screens/Forum/DetailForum2.tsx
Normal file
@@ -0,0 +1,306 @@
|
|||||||
|
import {
|
||||||
|
BoxButtonOnFooter,
|
||||||
|
ButtonCustom,
|
||||||
|
DrawerCustom,
|
||||||
|
LoaderCustom,
|
||||||
|
NewWrapper,
|
||||||
|
Spacing,
|
||||||
|
TextAreaCustom,
|
||||||
|
TextCustom,
|
||||||
|
TextInputCustom,
|
||||||
|
ViewWrapper,
|
||||||
|
} from "@/components";
|
||||||
|
import CustomSkeleton from "@/components/_ShareComponent/SkeletonCustom";
|
||||||
|
import AlertWarning from "@/components/Alert/AlertWarning";
|
||||||
|
import { MainColor } from "@/constants/color-palet";
|
||||||
|
import { useAuth } from "@/hooks/use-auth";
|
||||||
|
import { usePagination } from "@/hooks/use-pagination";
|
||||||
|
import { createPaginationComponents } from "@/helpers/paginationHelpers";
|
||||||
|
import Forum_CommentarBoxSection from "@/screens/Forum/CommentarBoxSection";
|
||||||
|
import Forum_BoxDetailSection from "@/screens/Forum/DiscussionBoxSection";
|
||||||
|
import Forum_MenuDrawerBerandaSection from "@/screens/Forum/MenuDrawerSection.tsx/MenuBeranda";
|
||||||
|
import Forum_MenuDrawerCommentar from "@/screens/Forum/MenuDrawerSection.tsx/MenuCommentar";
|
||||||
|
import {
|
||||||
|
apiForumCreateComment,
|
||||||
|
apiForumGetComment,
|
||||||
|
apiForumGetOne,
|
||||||
|
apiForumUpdateStatus,
|
||||||
|
} from "@/service/api-client/api-forum";
|
||||||
|
import { TypeForum_CommentProps } from "@/types/type-forum";
|
||||||
|
import { censorText, isBadContent } from "@/utils/badWordsIndonesia";
|
||||||
|
import { useFocusEffect, useLocalSearchParams } from "expo-router";
|
||||||
|
import _ from "lodash";
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { RefreshControl } from "react-native";
|
||||||
|
import ListSkeletonComponent from "@/components/_ShareComponent/ListSkeletonComponent";
|
||||||
|
|
||||||
|
export default function DetailForum2() {
|
||||||
|
const { id } = useLocalSearchParams();
|
||||||
|
const { user } = useAuth();
|
||||||
|
const [openDrawer, setOpenDrawer] = useState(false);
|
||||||
|
const [data, setData] = useState<any | null>(null);
|
||||||
|
const [isLoadingComment, setLoadingComment] = useState(false);
|
||||||
|
|
||||||
|
// Status
|
||||||
|
const [status, setStatus] = useState("");
|
||||||
|
const [text, setText] = useState("");
|
||||||
|
const [authorId, setAuthorId] = useState("");
|
||||||
|
const [dataId, setDataId] = useState("");
|
||||||
|
|
||||||
|
// Comentar
|
||||||
|
const [openDrawerCommentar, setOpenDrawerCommentar] = useState(false);
|
||||||
|
const [commentId, setCommentId] = useState("");
|
||||||
|
const [commentAuthorId, setCommentAuthorId] = useState("");
|
||||||
|
|
||||||
|
// Initialize pagination for comments
|
||||||
|
const commentPagination = usePagination({
|
||||||
|
fetchFunction: async (page) => {
|
||||||
|
return await apiForumGetComment({
|
||||||
|
id: id as string,
|
||||||
|
page: String(page), // API expects string
|
||||||
|
});
|
||||||
|
},
|
||||||
|
pageSize: 5,
|
||||||
|
dependencies: [id],
|
||||||
|
onError: (error) => console.error("[ERROR] Fetch forum comment:", error),
|
||||||
|
});
|
||||||
|
|
||||||
|
useFocusEffect(
|
||||||
|
useCallback(() => {
|
||||||
|
setTimeout(() => {
|
||||||
|
onLoadData(id as string);
|
||||||
|
}, 3000);
|
||||||
|
}, [id]),
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Reset and load first page of comments when id changes
|
||||||
|
commentPagination.reset();
|
||||||
|
commentPagination.onRefresh();
|
||||||
|
}, [id]);
|
||||||
|
|
||||||
|
const onLoadData = async (id: string) => {
|
||||||
|
try {
|
||||||
|
const response = await apiForumGetOne({ id });
|
||||||
|
setData(response.data);
|
||||||
|
} catch (error) {
|
||||||
|
console.log("[ERROR]", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Update Status
|
||||||
|
const handlerUpdateStatus = async (value: any) => {
|
||||||
|
try {
|
||||||
|
const response = await apiForumUpdateStatus({
|
||||||
|
id: id as string,
|
||||||
|
data: value,
|
||||||
|
});
|
||||||
|
if (response.success) {
|
||||||
|
setStatus(response.data);
|
||||||
|
setData({
|
||||||
|
...data,
|
||||||
|
ForumMaster_StatusPosting: {
|
||||||
|
status: response.data,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.log("[ERROR]", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create Commentar
|
||||||
|
const handlerCreateCommentar = async () => {
|
||||||
|
|
||||||
|
const cencorContent = censorText(text);
|
||||||
|
|
||||||
|
const newData = {
|
||||||
|
comment: cencorContent,
|
||||||
|
authorId: user?.id,
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
setLoadingComment(true);
|
||||||
|
const response = await apiForumCreateComment({
|
||||||
|
id: id as string,
|
||||||
|
data: newData,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.success) {
|
||||||
|
setText("");
|
||||||
|
const newComment = {
|
||||||
|
id: response.data.id,
|
||||||
|
isActive: response.data.isActive,
|
||||||
|
komentar: response.data.komentar,
|
||||||
|
createdAt: response.data.createdAt,
|
||||||
|
authorId: response.data.authorId,
|
||||||
|
Author: response.data.Author,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add new comment to the top of the list
|
||||||
|
commentPagination.setListData((prev) => [newComment, ...prev]);
|
||||||
|
|
||||||
|
setData({
|
||||||
|
...data,
|
||||||
|
count: data.count + 1,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.log("[ERROR]", error);
|
||||||
|
} finally {
|
||||||
|
setLoadingComment(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const headerComponent = () =>
|
||||||
|
// Box Posting
|
||||||
|
!data ? (
|
||||||
|
<CustomSkeleton height={200} />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{/* Area Posting */}
|
||||||
|
<Forum_BoxDetailSection
|
||||||
|
data={data}
|
||||||
|
onSetData={() => {
|
||||||
|
setOpenDrawer(true);
|
||||||
|
setStatus(data.ForumMaster_StatusPosting?.status);
|
||||||
|
setAuthorId(data.Author?.id);
|
||||||
|
setDataId(data.id);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Area Commentar */}
|
||||||
|
{data?.ForumMaster_StatusPosting?.status === "Open" && (
|
||||||
|
<>
|
||||||
|
<TextAreaCustom
|
||||||
|
placeholder="Ketik diskusi anda..."
|
||||||
|
maxLength={1000}
|
||||||
|
showCount
|
||||||
|
value={text}
|
||||||
|
onChangeText={setText}
|
||||||
|
style={{
|
||||||
|
marginBottom: 0,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<ButtonCustom
|
||||||
|
isLoading={isLoadingComment}
|
||||||
|
style={{
|
||||||
|
alignSelf: "flex-end",
|
||||||
|
}}
|
||||||
|
onPress={() => {
|
||||||
|
handlerCreateCommentar();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Balas
|
||||||
|
</ButtonCustom>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Render individual comment item
|
||||||
|
const renderCommentItem = ({ item }: { item: TypeForum_CommentProps }) =>
|
||||||
|
!data && !commentPagination.listData ? (
|
||||||
|
<ListSkeletonComponent />
|
||||||
|
) : (
|
||||||
|
<Forum_CommentarBoxSection
|
||||||
|
key={item.id}
|
||||||
|
data={item}
|
||||||
|
onSetData={(value) => {
|
||||||
|
setCommentId(value.setCommentId);
|
||||||
|
setOpenDrawerCommentar(value.setOpenDrawer);
|
||||||
|
setCommentAuthorId(value.setCommentAuthorId);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Generate pagination components using helper
|
||||||
|
const { ListEmptyComponent, ListFooterComponent } =
|
||||||
|
createPaginationComponents({
|
||||||
|
loading: commentPagination.loading,
|
||||||
|
refreshing: commentPagination.refreshing,
|
||||||
|
listData: commentPagination.listData,
|
||||||
|
isInitialLoad: commentPagination.isInitialLoad,
|
||||||
|
emptyMessage: "Tidak ada komentar",
|
||||||
|
skeletonCount: 3,
|
||||||
|
skeletonHeight: 120,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<NewWrapper
|
||||||
|
// headerComponent={}
|
||||||
|
listData={commentPagination.listData}
|
||||||
|
renderItem={renderCommentItem}
|
||||||
|
refreshControl={
|
||||||
|
<RefreshControl
|
||||||
|
// IOS
|
||||||
|
tintColor={MainColor.yellow}
|
||||||
|
// Android
|
||||||
|
colors={[MainColor.yellow]}
|
||||||
|
progressBackgroundColor={MainColor.yellow}
|
||||||
|
refreshing={commentPagination.refreshing}
|
||||||
|
onRefresh={commentPagination.onRefresh}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
onEndReached={commentPagination.loadMore}
|
||||||
|
ListHeaderComponent={
|
||||||
|
<>
|
||||||
|
{/* <Spacing height={40} />
|
||||||
|
<TextCustom color="gray">Komentar :</TextCustom>
|
||||||
|
<Spacing height={5} /> */}
|
||||||
|
{headerComponent()}
|
||||||
|
<Spacing />
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
ListFooterComponent={ListFooterComponent}
|
||||||
|
ListEmptyComponent={ListEmptyComponent}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Posting Drawer */}
|
||||||
|
<DrawerCustom
|
||||||
|
height={"auto"}
|
||||||
|
isVisible={openDrawer}
|
||||||
|
closeDrawer={() => setOpenDrawer(false)}
|
||||||
|
>
|
||||||
|
<Forum_MenuDrawerBerandaSection
|
||||||
|
id={dataId}
|
||||||
|
authorUsername={data?.Author?.username as string}
|
||||||
|
status={status}
|
||||||
|
setIsDrawerOpen={() => {
|
||||||
|
setOpenDrawer(false);
|
||||||
|
}}
|
||||||
|
authorId={authorId}
|
||||||
|
handlerUpdateStatus={(value: any) => {
|
||||||
|
handlerUpdateStatus(value);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</DrawerCustom>
|
||||||
|
|
||||||
|
{/* Commentar Drawer */}
|
||||||
|
<DrawerCustom
|
||||||
|
height={"auto"}
|
||||||
|
isVisible={openDrawerCommentar}
|
||||||
|
closeDrawer={() => setOpenDrawerCommentar(false)}
|
||||||
|
>
|
||||||
|
<Forum_MenuDrawerCommentar
|
||||||
|
id={commentId as string}
|
||||||
|
commentId={commentId}
|
||||||
|
commentAuthorId={commentAuthorId}
|
||||||
|
setIsDrawerOpen={() => {
|
||||||
|
setOpenDrawerCommentar(false);
|
||||||
|
}}
|
||||||
|
listComment={commentPagination.listData}
|
||||||
|
setListComment={commentPagination.setListData}
|
||||||
|
countComment={data?.count}
|
||||||
|
setCountComment={(val: any) => {
|
||||||
|
setData((prev: any) => ({
|
||||||
|
...prev,
|
||||||
|
count: val,
|
||||||
|
}));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</DrawerCustom>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
136
screens/Forum/ViewBeranda3.tsx
Normal file
136
screens/Forum/ViewBeranda3.tsx
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
import {
|
||||||
|
AvatarComp,
|
||||||
|
BackButton,
|
||||||
|
FloatingButton,
|
||||||
|
SearchInput
|
||||||
|
} from "@/components";
|
||||||
|
import NewWrapper from "@/components/_ShareComponent/NewWrapper";
|
||||||
|
import { MainColor } from "@/constants/color-palet";
|
||||||
|
import { createPaginationComponents } from "@/helpers/paginationHelpers";
|
||||||
|
import { useAuth } from "@/hooks/use-auth";
|
||||||
|
import { usePagination } from "@/hooks/use-pagination";
|
||||||
|
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, TouchableOpacity, View } from "react-native";
|
||||||
|
|
||||||
|
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: () => (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => router.navigate(`/forum/${user?.id}/forumku`)}
|
||||||
|
>
|
||||||
|
<AvatarComp
|
||||||
|
fileId={dataUser?.Profile?.imageId}
|
||||||
|
size="base"
|
||||||
|
href={`/forum/${user?.id}/forumku`}
|
||||||
|
/>
|
||||||
|
</TouchableOpacity>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<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}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -15,38 +15,49 @@ import NoDataText from "@/components/_ShareComponent/NoDataText";
|
|||||||
import CustomSkeleton from "@/components/_ShareComponent/SkeletonCustom";
|
import CustomSkeleton from "@/components/_ShareComponent/SkeletonCustom";
|
||||||
import { MainColor } from "@/constants/color-palet";
|
import { MainColor } from "@/constants/color-palet";
|
||||||
import { useAuth } from "@/hooks/use-auth";
|
import { useAuth } from "@/hooks/use-auth";
|
||||||
|
import { usePagination } from "@/hooks/use-pagination";
|
||||||
|
import { createPaginationComponents } from "@/helpers/paginationHelpers";
|
||||||
import Forum_BoxDetailSection from "@/screens/Forum/DiscussionBoxSection";
|
import Forum_BoxDetailSection from "@/screens/Forum/DiscussionBoxSection";
|
||||||
import { apiForumGetAll } from "@/service/api-client/api-forum";
|
import { apiForumGetAll } from "@/service/api-client/api-forum";
|
||||||
import { apiUser } from "@/service/api-client/api-user";
|
import { apiUser } from "@/service/api-client/api-user";
|
||||||
import { router, useLocalSearchParams } from "expo-router";
|
import { router, useLocalSearchParams } from "expo-router";
|
||||||
import _ from "lodash";
|
import _ from "lodash";
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { RefreshControl, View } from "react-native";
|
import { RefreshControl, View } from "react-native";
|
||||||
|
|
||||||
const PAGE_SIZE = 5;
|
|
||||||
|
|
||||||
export default function View_Forumku2() {
|
export default function View_Forumku2() {
|
||||||
const { id } = useLocalSearchParams();
|
const { id } = useLocalSearchParams();
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const [listData, setListData] = useState<any[]>([]);
|
|
||||||
const [dataUser, setDataUser] = useState<any | null>(null);
|
const [dataUser, setDataUser] = useState<any | null>(null);
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [refreshing, setRefreshing] = useState(false);
|
|
||||||
const [hasMore, setHasMore] = useState(true);
|
|
||||||
const [page, setPage] = useState(1);
|
|
||||||
const [count, setCount] = useState(0);
|
const [count, setCount] = useState(0);
|
||||||
|
|
||||||
|
// Initialize pagination hook
|
||||||
|
const pagination = usePagination({
|
||||||
|
fetchFunction: async (page) => {
|
||||||
|
if (!user?.id) throw new Error("User not authenticated");
|
||||||
|
|
||||||
|
const response = await apiForumGetAll({
|
||||||
|
category: "forumku",
|
||||||
|
authorId: id as string,
|
||||||
|
userLoginId: user.id,
|
||||||
|
page: String(page), // API terima string
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update count when fetching page 1
|
||||||
|
if (page === 1) {
|
||||||
|
setCount(response.data.count);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
pageSize: 5,
|
||||||
|
dependencies: [user?.id],
|
||||||
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
onLoadDataProfile(id as string);
|
onLoadDataProfile(id as string);
|
||||||
}, [id]);
|
}, [id]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setPage(1);
|
|
||||||
setListData([]);
|
|
||||||
setHasMore(true);
|
|
||||||
fetchData(1, true);
|
|
||||||
}, [user?.id]);
|
|
||||||
|
|
||||||
const onLoadDataProfile = async (id: string) => {
|
const onLoadDataProfile = async (id: string) => {
|
||||||
try {
|
try {
|
||||||
const response = await apiUser(id);
|
const response = await apiUser(id);
|
||||||
@@ -58,54 +69,6 @@ export default function View_Forumku2() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 🔹 Fungsi fetch data
|
|
||||||
const fetchData = async (pageNumber: number, clear: boolean) => {
|
|
||||||
if (!user?.id) return;
|
|
||||||
|
|
||||||
// Cegah multiple call
|
|
||||||
if (!clear && (loading || refreshing)) return;
|
|
||||||
|
|
||||||
const isRefresh = clear;
|
|
||||||
if (isRefresh) setRefreshing(true);
|
|
||||||
if (!isRefresh) setLoading(true);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await apiForumGetAll({
|
|
||||||
category: "forumku",
|
|
||||||
authorId: id as string,
|
|
||||||
userLoginId: user.id,
|
|
||||||
page: String(pageNumber), // API terima string
|
|
||||||
});
|
|
||||||
|
|
||||||
const newData = response.data.data || [];
|
|
||||||
setListData((prev) => {
|
|
||||||
const current = Array.isArray(prev) ? prev : [];
|
|
||||||
return clear ? newData : [...current, ...newData];
|
|
||||||
});
|
|
||||||
setHasMore(newData.length === PAGE_SIZE);
|
|
||||||
setPage(pageNumber);
|
|
||||||
setCount(response.data.count);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("[ERROR] Fetch forum:", error);
|
|
||||||
setHasMore(false);
|
|
||||||
} finally {
|
|
||||||
setRefreshing(false);
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 🔹 Pull-to-refresh
|
|
||||||
const onRefresh = useCallback(() => {
|
|
||||||
fetchData(1, true);
|
|
||||||
}, [user?.id]);
|
|
||||||
|
|
||||||
// 🔹 Infinite scroll
|
|
||||||
const loadMore = useCallback(() => {
|
|
||||||
if (hasMore && !loading && !refreshing) {
|
|
||||||
fetchData(page + 1, false);
|
|
||||||
}
|
|
||||||
}, [hasMore, loading, refreshing, page, user?.id]);
|
|
||||||
|
|
||||||
const randerHeaderComponent = () => (
|
const randerHeaderComponent = () => (
|
||||||
<>
|
<>
|
||||||
<CenterCustom>
|
<CenterCustom>
|
||||||
@@ -144,39 +107,16 @@ export default function View_Forumku2() {
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
// Skeleton List (untuk initial load)
|
// Generate pagination components using helper
|
||||||
const SkeletonListComponent = () => (
|
const { ListEmptyComponent, ListFooterComponent } = createPaginationComponents({
|
||||||
<View style={{ flex: 1 }}>
|
loading: pagination.loading,
|
||||||
<StackCustom>
|
refreshing: pagination.refreshing,
|
||||||
{Array.from({ length: 5 }).map((_, i) => (
|
listData: pagination.listData,
|
||||||
<CustomSkeleton height={200} key={i} />
|
isInitialLoad: pagination.isInitialLoad,
|
||||||
))}
|
emptyMessage: "Tidak ada postingan",
|
||||||
</StackCustom>
|
skeletonCount: 5,
|
||||||
</View>
|
skeletonHeight: 200,
|
||||||
);
|
});
|
||||||
|
|
||||||
// Komponen Empty
|
|
||||||
const EmptyComponent = () => (
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
flex: 1,
|
|
||||||
justifyContent: "center",
|
|
||||||
alignItems: "center",
|
|
||||||
padding: 20,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<NoDataText />
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
|
|
||||||
// 🔹 Komponen Footer List (loading indicator)
|
|
||||||
const ListFooterComponent =
|
|
||||||
loading && !refreshing && listData.length > 0 ? (
|
|
||||||
<View style={{ paddingVertical: 16, alignItems: "center" }}>
|
|
||||||
{/* <Text style={{ color: "#aaa", fontSize: 14 }}>Memuat diskusi...</Text> */}
|
|
||||||
<LoaderCustom />
|
|
||||||
</View>
|
|
||||||
) : null;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -190,7 +130,7 @@ export default function View_Forumku2() {
|
|||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
listData={listData}
|
listData={pagination.listData}
|
||||||
renderItem={renderList}
|
renderItem={renderList}
|
||||||
refreshControl={
|
refreshControl={
|
||||||
<RefreshControl
|
<RefreshControl
|
||||||
@@ -199,16 +139,14 @@ export default function View_Forumku2() {
|
|||||||
// Android
|
// Android
|
||||||
colors={[MainColor.yellow]}
|
colors={[MainColor.yellow]}
|
||||||
progressBackgroundColor={MainColor.yellow}
|
progressBackgroundColor={MainColor.yellow}
|
||||||
refreshing={refreshing}
|
refreshing={pagination.refreshing}
|
||||||
onRefresh={onRefresh}
|
onRefresh={pagination.onRefresh}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
onEndReached={loadMore}
|
onEndReached={pagination.loadMore}
|
||||||
ListHeaderComponent={randerHeaderComponent()}
|
ListHeaderComponent={randerHeaderComponent()}
|
||||||
ListFooterComponent={ListFooterComponent}
|
ListFooterComponent={ListFooterComponent}
|
||||||
ListEmptyComponent={
|
ListEmptyComponent={ListEmptyComponent}
|
||||||
loading && _.isEmpty(listData) ? <SkeletonListComponent /> : <EmptyComponent />
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ const AvatarAndBackground = ({
|
|||||||
`/(application)/(image)/preview-image/${backgroundId}`
|
`/(application)/(image)/preview-image/${backgroundId}`
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
|
disabled={!backgroundId}
|
||||||
>
|
>
|
||||||
<ImageBackground
|
<ImageBackground
|
||||||
source={
|
source={
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { BackButton } from "@/components";
|
||||||
import { MainColor } from "@/constants/color-palet";
|
import { MainColor } from "@/constants/color-palet";
|
||||||
import { Stack } from "expo-router";
|
import { Stack } from "expo-router";
|
||||||
|
|
||||||
@@ -15,8 +16,18 @@ export default function AppRoot() {
|
|||||||
name="index"
|
name="index"
|
||||||
options={{ title: "", headerBackVisible: false }}
|
options={{ title: "", headerBackVisible: false }}
|
||||||
/>
|
/>
|
||||||
<Stack.Screen name="eula" options={{ title: "Terms & Conditions", headerBackVisible: false }} />
|
<Stack.Screen
|
||||||
<Stack.Screen name="+not-found" options={{ title: "" }} />
|
name="eula"
|
||||||
|
options={{ title: "Terms & Conditions", headerBackVisible: false }}
|
||||||
|
/>
|
||||||
|
{/* CEK PADA FILE */}
|
||||||
|
{/* <Stack.Screen
|
||||||
|
options={{
|
||||||
|
headerShown: true,
|
||||||
|
title: "",
|
||||||
|
headerLeft: () => <BackButton />,
|
||||||
|
}}
|
||||||
|
/> */}
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
name="verification"
|
name="verification"
|
||||||
options={{ title: "", headerBackVisible: false }}
|
options={{ title: "", headerBackVisible: false }}
|
||||||
|
|||||||
105
screens/UserSeach/MainView.tsx
Normal file
105
screens/UserSeach/MainView.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
174
screens/UserSeach/MainView_V2.tsx
Normal file
174
screens/UserSeach/MainView_V2.tsx
Normal 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>
|
||||||
|
// </>
|
||||||
|
// );
|
||||||
|
}
|
||||||
@@ -102,9 +102,9 @@ export async function apiForumCreateComment({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function apiForumGetComment({ id }: { id: string }) {
|
export async function apiForumGetComment({ id, page = "1" }: { id: string, page?: string }) {
|
||||||
try {
|
try {
|
||||||
const response = await apiConfig.get(`/mobile/forum/${id}/comment`);
|
const response = await apiConfig.get(`/mobile/forum/${id}/comment?page=${page}`);
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw error;
|
throw error;
|
||||||
|
|||||||
@@ -5,12 +5,27 @@ export async function apiUser(id: string) {
|
|||||||
return response.data;
|
return response.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function apiAllUser({ search }: { search: string }) {
|
export async function apiAllUser({
|
||||||
const response = await apiConfig.get(`/mobile/user?search=${search}`);
|
page,
|
||||||
return response.data;
|
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}`);
|
const response = await apiConfig.delete(`/mobile/user/${id}`);
|
||||||
return response.data;
|
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 {
|
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;
|
return response.data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user