Compare commits

..

9 Commits

Author SHA1 Message Date
ec79a1fbcd Fix semua tampilan yang memiliki fungsi infitine load
UI – User Notifications
- app/(application)/(user)/notifications/index.tsx
- service/api-notifications.ts
- screens/Notification/

UI – Portofolio (User)
- app/(application)/(user)/portofolio/[id]/create.tsx
- app/(application)/(user)/portofolio/[id]/edit.tsx
- app/(application)/(user)/portofolio/[id]/list.tsx
- screens/Portofolio/BoxPortofolioView.tsx
- screens/Portofolio/ViewListPortofolio.tsx
- screens/Profile/PortofolioSection.tsx
- service/api-client/api-portofolio.ts

Forum & User Search
- screens/Forum/DetailForum2.tsx
- screens/Forum/ViewBeranda3.tsx
- screens/UserSeach/MainView_V2.tsx

Constants & Docs
- constants/constans-value.ts
- docs/prompt-for-qwen-code.md

### No Issue
2026-01-30 17:18:47 +08:00
ed16f1b204 Fix forum detail
Forum (User)
- app/(application)/(user)/forum/[id]/index.tsx
- screens/Forum/ViewForumku2.tsx
- service/api-client/api-forum.ts

Forum – New / Refactor
- screens/Forum/DetailForum.tsx
- screens/Forum/DetailForum2.tsx

Documentation
- docs/

Removed
- hipmi-note.md

### No Issue'
2026-01-29 17:36:17 +08:00
d693550a1f Fix Alur Login & Load data forum , user search
Admin – User Access
- app/(application)/admin/user-access/[id]/index.tsx

Authentication
- context/AuthContext.tsx
- screens/Authentication/EULASection.tsx
- screens/Authentication/LoginView.tsx

Forum
- screens/Forum/ViewBeranda3.tsx

Profile & UI Components
- components/Image/AvatarComp.tsx
- screens/Profile/AvatarAndBackground.tsx

### No Issue
2026-01-29 15:08:00 +08:00
b3bfbc0f7e Fix Infinite Load Data
Forum & User Search – User
- app/(application)/(user)/forum/index.tsx
- app/(application)/(user)/user-search/index.tsx

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

Component
- components/Image/AvatarComp.tsx

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

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

### No Issue
2026-01-29 11:36:24 +08:00
71e45d06cc Donation – User
- app/(application)/(user)/donation/(tabs)/index.tsx
- app/(application)/(user)/donation/(tabs)/my-donation.tsx
- app/(application)/(user)/donation/[id]/(transaction-flow)/index.tsx

Donation – Admin
- app/(application)/admin/donation/[id]/disbursement-of-funds.tsx

Image Preview
- app/(application)/(image)/preview-image/[id]/index.tsx

### No Issue
2026-01-27 17:42:14 +08:00
07e64c335e Donation – App & Admin
- app/(application)/(user)/donation/(tabs)/status.tsx
- app/(application)/(user)/donation/create-story.tsx
- app/(application)/admin/donation/[id]/[status]/transaction-detail.tsx
- app/(application)/admin/donation/[id]/reject-input.tsx
- screens/Admin/Donation/funDonationUpdateStatus.ts
- service/api-admin/api-admin-donation.ts

Config
- app.config.js
- ios/HIPMIBadungConnect/Info.plist

### No Issue
2026-01-23 17:13:06 +08:00
1aebc9b4e8 Logika EULA
Dipindah ke halaman login dan cek dengan modal

Fix:
Authentication & EULA
- context/AuthContext.tsx
- screens/Authentication/EULAView.tsx
- screens/Authentication/LoginView.tsx

Add:
- components/Modal/ModalReactNative.tsx
- screens/Authentication/EULASection.tsx

### No Issue
2026-01-23 14:45:44 +08:00
5665dc88ba Notification investasi done
### No Isssue
2026-01-22 17:54:33 +08:00
da82a02a45 Investment UI (Admin & User)
- app/(application)/(user)/investment/[id]/(transaction-flow)/invoice.tsx
- app/(application)/admin/investment/[id]/[status]/index.tsx
- app/(application)/admin/investment/[id]/[status]/transaction-detail.tsx
- app/(application)/admin/investment/[status]/status.tsx

API Service (Admin)
- service/api-admin/api-admin-investment.ts

### No issue
2026-01-21 15:39:08 +08:00
61 changed files with 3298 additions and 1026 deletions

179
QWEN.md Normal file
View File

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

View File

@@ -21,7 +21,7 @@ export default {
"Aplikasi membutuhkan akses lokasi untuk menampilkan peta.",
},
associatedDomains: ["applinks:cld-dkr-staging-hipmi.wibudev.com"],
buildNumber: "19",
buildNumber: "20",
},
android: {

View File

@@ -1,8 +1,10 @@
import { CenterCustom, TextCustom, ViewWrapper } from "@/components";
import API_STRORAGE from "@/constants/base-url-api-strorage";
import { MainColor } from "@/constants/color-palet";
import { Image } from "expo-image";
import { useLocalSearchParams } from "expo-router";
import React, { useState } from "react";
import { View } from "react-native";
export default function PreviewImage() {
const { id } = useLocalSearchParams();
@@ -11,18 +13,48 @@ export default function PreviewImage() {
return (
<ViewWrapper>
{id ? (
<Image
onLoad={() => {
setIsLoading(false);
<View
style={{
width: "100%",
height: "100%",
position: "relative",
}}
source={
isLoading
? require("@/assets/images/loading.gif")
: API_STRORAGE.GET({ fileId: id as string })
}
contentFit="contain"
style={{ width: "100%", height: "100%" }}
/>
>
{/* Main Image */}
<Image
onLoad={() => {
setIsLoading(false);
}}
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>
<TextCustom>File not found</TextCustom>

View File

@@ -26,7 +26,6 @@ export default function DonationBeranda() {
const response = await apiDonationGetAll({
category: "beranda"
});
console.log("[RES GET ALL]", JSON.stringify(response.data, null, 2));
setList(response.data);
} catch (error) {

View File

@@ -16,6 +16,7 @@ import { Href, router, useFocusEffect } from "expo-router";
import _ from "lodash";
import { useCallback, useState } from "react";
import { View } from "react-native";
import Toast from "react-native-toast-message";
export default function DonationMyDonation() {
const { user } = useAuth();
@@ -25,20 +26,25 @@ export default function DonationMyDonation() {
useFocusEffect(
useCallback(() => {
onLoadData();
}, [user?.id])
}, [user?.id]),
);
const onLoadData = async () => {
if (!user?.id) {
Toast.show({
type: "error",
text1: "Load data gagal, user tidak ditemukan",
});
return;
}
try {
setLoadList(true);
const response = await apiDonationGetAll({
category: "my-donation",
authorId: user?.id,
});
console.log(
"[RES GET MY DONATION]",
JSON.stringify(response.data, null, 2)
);
setList(response.data);
} catch (error) {

View File

@@ -9,14 +9,16 @@ import { useAuth } from "@/hooks/use-auth";
import { dummyMasterStatus } from "@/lib/dummy-data/_master/status";
import Donasi_BoxStatus from "@/screens/Donation/BoxStatus";
import { apiDonationGetByStatus } from "@/service/api-client/api-donation";
import { useFocusEffect } from "expo-router";
import { useFocusEffect, useLocalSearchParams } from "expo-router";
import _ from "lodash";
import { useCallback, useState } from "react";
export default function DonationStatus() {
const { user } = useAuth();
const { status } = useLocalSearchParams<{ status?: string }>();
const [activeCategory, setActiveCategory] = useState<string | null>(
"publish"
status || "publish",
);
const [listData, setListData] = useState<any[] | null>(null);
const [loadList, setLoadList] = useState(false);
@@ -24,7 +26,7 @@ export default function DonationStatus() {
useFocusEffect(
useCallback(() => {
onLoadList();
}, [activeCategory])
}, [activeCategory]),
);
const onLoadList = async () => {

View File

@@ -10,21 +10,32 @@ import {
import { MainColor } from "@/constants/color-palet";
import { ICON_SIZE_SMALL } from "@/constants/constans-value";
import { LOCAL_STORAGE_KEY } from "@/constants/local-storage-key";
import { useAuth } from "@/hooks/use-auth";
import { formatCurrencyDisplay } from "@/utils/formatCurrencyDisplay";
import { Ionicons } from "@expo/vector-icons";
import AsyncStorage from "@react-native-async-storage/async-storage";
import { router, useLocalSearchParams } from "expo-router";
import { useState } from "react";
import Toast from "react-native-toast-message";
export default function InvestmentInputDonation() {
const { user } = useAuth();
const { id } = useLocalSearchParams();
const [nominal, setNominal] = useState<number>(0);
const handlerSubmit = async () => {
if (!user?.id) {
Toast.show({
type: "error",
text1: "User tidak ditemukan",
});
return;
}
try {
await AsyncStorage.setItem(
LOCAL_STORAGE_KEY.transactionDonation,
JSON.stringify({ nominal: nominal.toString() })
JSON.stringify({ nominal: nominal.toString() }),
);
router.replace(`/donation/${id}/select-bank`);
} catch (error) {

View File

@@ -103,7 +103,7 @@ export default function DonationCreateStory() {
type: "success",
text1: "Donasi berhasil disimpan",
});
router.replace("/donation/status");
router.replace("/donation/status?status=review");
} catch (error) {
console.log("[ERROR]", error);
} finally {

View File

@@ -1,258 +1,11 @@
import {
ButtonCustom,
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";
import DetailForum from "@/screens/Forum/DetailForum";
import DetailForum2 from "@/screens/Forum/DetailForum2";
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 (
<>
<ViewWrapper>
{!data && !listComment ? (
<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>
{/* <DetailForum />; */}
<DetailForum2 />
</>
);
)
}

View File

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

View File

@@ -115,7 +115,11 @@ export default function InvestmentAddNews() {
onChangeText={(value) => setData({ ...data, deskripsi: value })}
/>
<ButtonCustom isLoading={isLoading} onPress={handlerSubmit}>
<ButtonCustom
disabled={!data.title || !data.deskripsi || isLoading}
isLoading={isLoading}
onPress={handlerSubmit}
>
Simpan
</ButtonCustom>
</StackCustom>

View File

@@ -27,7 +27,6 @@ import Toast from "react-native-toast-message";
export default function InvestmentInvoice() {
const { id } = useLocalSearchParams();
console.log("[ID]", id);
const [data, setData] = useState<any>({});
const [image, setImage] = useState<IFileData>({
name: "",
@@ -49,7 +48,6 @@ export default function InvestmentInvoice() {
category: "invoice",
});
console.log("[RES INVOICE]", JSON.stringify(response.data, null, 2));
setData(response.data);
} catch (error) {
console.log("[ERROR]", error);
@@ -64,8 +62,6 @@ export default function InvestmentInvoice() {
imageUri: image?.uri,
});
console.log("[RESPONSE UPLOAD IMAGE]", responseUploadImage);
if (!responseUploadImage?.data?.id) {
Toast.show({
type: "error",
@@ -83,10 +79,6 @@ export default function InvestmentInvoice() {
});
if (response.success) {
console.log(
"[RESPONSE UPDATE]",
JSON.stringify(response.data, null, 2)
);
Toast.show({
type: "success",
text1: "Berhasil mengunggah bukti transfer",
@@ -210,7 +202,6 @@ export default function InvestmentInvoice() {
pickFile({
allowedType: "image",
setImageUri(file: any) {
console.log("[IMAGE]", file);
setImage(file);
},
});
@@ -224,7 +215,7 @@ export default function InvestmentInvoice() {
<ButtonCustom
isLoading={isLoading}
disabled={!image}
disabled={!image || isLoading}
onPress={() => {
handlerSubmitUpdate();
}}

View File

@@ -18,10 +18,7 @@ import {
apiInvestmentUpdateData,
} from "@/service/api-client/api-investment";
import { apiMasterInvestment } from "@/service/api-client/api-master";
import {
deleteFileService,
uploadFileService,
} from "@/service/upload-service";
import { deleteFileService, uploadFileService } from "@/service/upload-service";
import { formatCurrencyDisplay } from "@/utils/formatCurrencyDisplay";
import pickFile from "@/utils/pickFile";
import { router, useFocusEffect, useLocalSearchParams } from "expo-router";
@@ -70,7 +67,7 @@ export default function InvestmentEdit() {
useCallback(() => {
onLoadMaster();
onLoadData();
}, [id])
}, [id]),
);
const onLoadMaster = async () => {
@@ -178,7 +175,7 @@ export default function InvestmentEdit() {
const responseUpdate = await apiInvestmentUpdateData({
id: id as string,
data: newData,
category: "data"
category: "data",
});
if (responseUpdate.success) {
@@ -256,6 +253,7 @@ export default function InvestmentEdit() {
/>
<TextInputCustom
disabled
required
placeholder="0"
label="Total Lembar"

View File

@@ -54,7 +54,7 @@ export default function InvestmentCreate() {
useFocusEffect(
useCallback(() => {
onLoadMaster();
}, [])
}, []),
);
const onLoadMaster = async () => {
@@ -264,6 +264,7 @@ export default function InvestmentCreate() {
<StackCustom gap={0}>
<TextInputCustom
disabled
required
placeholder="0"
label="Total Lembar"

View File

@@ -1,248 +1,9 @@
/* eslint-disable react-hooks/exhaustive-deps */
import {
AlertDefaultSystem,
BackButton,
BaseBox,
DrawerCustom,
MenuDrawerDynamicGrid,
NewWrapper,
ScrollableCustom,
StackCustom,
TextCustom,
} from "@/components";
import { IconDot } from "@/components/_Icon/IconComponent";
import ListSkeletonComponent from "@/components/_ShareComponent/ListSkeletonComponent";
import NoDataText from "@/components/_ShareComponent/NoDataText";
import { AccentColor, MainColor } from "@/constants/color-palet";
import { ICON_SIZE_SMALL } from "@/constants/constans-value";
import { useAuth } from "@/hooks/use-auth";
import { useNotificationStore } from "@/hooks/use-notification-store";
import { apiGetNotificationsById } from "@/service/api-notifications";
import { listOfcategoriesAppNotification } from "@/types/type-notification-category";
import { formatChatTime } from "@/utils/formatChatTime";
import { Ionicons } from "@expo/vector-icons";
import { router, Stack, useFocusEffect, useLocalSearchParams } from "expo-router";
import _ from "lodash";
import { useCallback, useState } from "react";
import { RefreshControl, View } from "react-native";
import ScreenNotification from "@/screens/Notification/ScreenNotification";
const selectedCategory = (value: string) => {
const category = listOfcategoriesAppNotification.find(
(c) => c.value === value
);
return category?.label;
};
const fixPath = ({
deepLink,
categoryApp,
}: {
deepLink: string;
categoryApp: string;
}) => {
if (categoryApp === "OTHER") {
return deepLink;
}
const separator = deepLink.includes("?") ? "&" : "?";
const fixedPath = `${deepLink}${separator}from=notifications&category=${_.lowerCase(
categoryApp
)}`;
console.log("Fix Path", fixedPath);
return fixedPath;
};
const BoxNotification = ({
data,
activeCategory,
}: {
data: any;
activeCategory: string | null;
}) => {
// console.log("DATA NOTIFICATION", JSON.stringify(data, null, 2));
const { markAsRead } = useNotificationStore();
export default function Notification() {
return (
<>
<BaseBox
backgroundColor={data.isRead ? AccentColor.darkblue : AccentColor.blue}
onPress={() => {
// console.log(
// "Notification >",
// selectedCategory(activeCategory as string)
// );
const newPath = fixPath({
deepLink: data.deepLink,
categoryApp: data.kategoriApp,
});
router.navigate(newPath as any);
selectedCategory(activeCategory as string);
if (!data.isRead) {
markAsRead(data.id);
}
}}
>
<StackCustom>
<TextCustom truncate={2} bold>
{data.title}
</TextCustom>
<TextCustom truncate={2}>{data.pesan}</TextCustom>
<TextCustom size="small" color="gray">
{formatChatTime(data.createdAt)}
</TextCustom>
</StackCustom>
</BaseBox>
</>
);
};
export default function Notifications() {
const { user } = useAuth();
const { category } = useLocalSearchParams<{ category?: string }>();
const [activeCategory, setActiveCategory] = useState<string | null>(
category || "event"
);
const [listData, setListData] = useState<any[]>([]);
const [refreshing, setRefreshing] = useState(false);
const [loading, setLoading] = useState(false);
const [openDrawer, setOpenDrawer] = useState(false);
const { markAsReadAll } = useNotificationStore();
const handlePress = (item: any) => {
setActiveCategory(item.value);
// tambahkan logika lain seperti filter dsb.
};
useFocusEffect(
useCallback(() => {
fecthData();
}, [activeCategory])
);
const fecthData = async () => {
try {
setLoading(true);
const response = await apiGetNotificationsById({
id: user?.id as any,
category: activeCategory as any,
});
if (response.success) {
setListData(response.data);
} else {
setListData([]);
}
} catch (error) {
console.log("Error Notification", error);
} finally {
setLoading(false);
}
};
const onRefresh = () => {
setRefreshing(true);
fecthData();
setRefreshing(false);
};
return (
<>
<Stack.Screen
options={{
title: "Notifikasi",
headerLeft: () => <BackButton />,
headerRight: () => (
<IconDot
color={MainColor.yellow}
onPress={() => setOpenDrawer(true)}
/>
),
}}
/>
<NewWrapper
headerComponent={
<ScrollableCustom
data={listOfcategoriesAppNotification.map((e, i) => ({
id: i,
label: e.label,
value: e.value,
}))}
onButtonPress={handlePress}
activeId={activeCategory as string}
/>
}
refreshControl={
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />
}
>
{loading ? (
<ListSkeletonComponent />
) : _.isEmpty(listData) ? (
<NoDataText text="Belum ada notifikasi" />
) : (
listData.map((e, i) => (
<View key={i}>
<BoxNotification
data={e}
activeCategory={activeCategory as any}
/>
</View>
))
)}
</NewWrapper>
<DrawerCustom
isVisible={openDrawer}
closeDrawer={() => setOpenDrawer(false)}
height={"auto"}
>
<MenuDrawerDynamicGrid
data={[
{
label: "Tandai Semua Dibaca",
value: "read-all",
icon: (
<Ionicons
name="reader-outline"
size={ICON_SIZE_SMALL}
color={MainColor.white}
/>
),
path: "",
},
]}
onPressItem={(item: any) => {
console.log("Item", item.value);
if (item.value === "read-all") {
AlertDefaultSystem({
title: "Tandai Semua Dibaca",
message:
"Apakah Anda yakin ingin menandai semua notifikasi dibaca?",
textLeft: "Batal",
textRight: "Ya",
onPressRight: () => {
markAsReadAll(user?.id as any);
const data = _.cloneDeep(listData);
data.forEach((e) => {
e.isRead = true;
});
setListData(data);
onRefresh();
setOpenDrawer(false);
},
});
}
}}
/>
</DrawerCustom>
<ScreenNotification />
</>
);
}

View File

@@ -7,6 +7,7 @@ import {
CenterCustom,
Grid,
InformationBox,
NewWrapper,
SelectCustom,
Spacing,
StackCustom,
@@ -120,7 +121,7 @@ export default function PortofolioCreate() {
};
return (
<ViewWrapper
<NewWrapper
footerComponent={
<Portofolio_ButtonCreate
id={id as string}
@@ -357,8 +358,8 @@ export default function PortofolioCreate() {
setDataMedsos({ ...dataMedsos, youtube: value })
}
/>
<Spacing />
{/* <Spacing /> */}
</StackCustom>
</ViewWrapper>
</NewWrapper>
);
}

View File

@@ -4,14 +4,15 @@ import {
BoxButtonOnFooter,
ButtonCustom,
CenterCustom,
NewWrapper,
SelectCustom,
Spacing,
StackCustom,
TextAreaCustom,
TextCustom,
TextInputCustom,
ViewWrapper,
} from "@/components";
import ListSkeletonComponent from "@/components/_ShareComponent/ListSkeletonComponent";
import { MainColor } from "@/constants/color-palet";
import { ICON_SIZE_XLARGE } from "@/constants/constans-value";
import {
@@ -238,7 +239,7 @@ export default function PortofolioEdit() {
return !dataArray.some(
(item: any) =>
!item.MasterSubBidangBisnis.id ||
item.MasterSubBidangBisnis.id.trim() === ""
item.MasterSubBidangBisnis.id.trim() === "",
);
}
@@ -319,16 +320,16 @@ export default function PortofolioEdit() {
if (!bidangBisnis || !subBidangBisnis) {
return (
<>
<ViewWrapper>
<ActivityIndicator size="large" color={MainColor.yellow} />
</ViewWrapper>
<NewWrapper>
<ListSkeletonComponent height={80} />
</NewWrapper>
</>
);
}
return (
<>
<ViewWrapper footerComponent={buttonUpdate}>
<NewWrapper footerComponent={buttonUpdate}>
<StackCustom gap={"xs"}>
<TextInputCustom
required
@@ -471,7 +472,7 @@ export default function PortofolioEdit() {
/>
<Spacing />
</StackCustom>
</ViewWrapper>
</NewWrapper>
</>
);
}

View File

@@ -1,28 +1,9 @@
import { TextCustom, ViewWrapper } from "@/components";
import Portofolio_BoxView from "@/screens/Portofolio/BoxPortofolioView";
import { apiGetPortofolio } from "@/service/api-client/api-portofolio";
import { useFocusEffect, useLocalSearchParams } from "expo-router";
import { useCallback, useState } from "react";
import ViewListPortofolio from "@/screens/Portofolio/ViewListPortofolio";
export default function ListPortofolio() {
const { id } = useLocalSearchParams();
const [data, setData] = useState<any[]>([]);
useFocusEffect(
useCallback(() => {
onLoadPortofolio(id as string);
}, [id])
);
const onLoadPortofolio = async (id: string) => {
const response = await apiGetPortofolio({ id: id });
setData(response.data);
};
return (
<ViewWrapper>
{data ? data?.map((item: any, index: number) => (
<Portofolio_BoxView key={index} data={item} />
)) : <TextCustom>Tidak ada portofolio</TextCustom>}
</ViewWrapper>
<>
<ViewListPortofolio />
</>
);
}

View File

@@ -36,7 +36,7 @@ export default function ProfileLayout() {
<Stack.Screen
name="[id]/blocked-list"
options={{ title: "Blocked List", headerLeft: () => <BackButton /> }}
options={{ title: "Daftar Blokir", headerLeft: () => <BackButton /> }}
/>
<Stack.Screen

View File

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

View File

@@ -1,4 +1,5 @@
import {
AlertDefaultSystem,
BadgeCustom,
BaseBox,
BoxButtonOnFooter,
@@ -9,6 +10,7 @@ import {
} from "@/components";
import AdminBackButtonAntTitle from "@/components/_ShareComponent/Admin/BackButtonAntTitle";
import { GridSpan_4_8 } from "@/components/_ShareComponent/GridSpan_4_8";
import { useAuth } from "@/hooks/use-auth";
import {
apiAdminDonationInvoiceDetailById,
apiAdminDonationInvoiceUpdateById,
@@ -22,6 +24,7 @@ import { useCallback, useState } from "react";
import Toast from "react-native-toast-message";
export default function AdminDonasiTransactionDetail() {
const { user } = useAuth();
const { id, status } = useLocalSearchParams();
console.log("[STATUS]", id, status);
@@ -33,7 +36,7 @@ export default function AdminDonasiTransactionDetail() {
onLoadData();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [id])
}, [id]),
);
const onLoadData = async () => {
@@ -57,6 +60,7 @@ export default function AdminDonasiTransactionDetail() {
const newData = {
donationId: data?.donasiId,
nominal: data?.nominal,
senderId: user?.id,
};
const response = await apiAdminDonationInvoiceUpdateById({
@@ -97,7 +101,15 @@ export default function AdminDonasiTransactionDetail() {
<ButtonCustom
isLoading={isLoading}
onPress={() => {
handlerSubmit();
AlertDefaultSystem({
title: "Konfirmasi transaksi",
message: "Apakah anda yakin ingin menyetujui transaksi ini?",
textLeft: "Tidak",
textRight: "Ya",
onPressRight: () => {
handlerSubmit();
},
});
}}
>
Terima donasi
@@ -109,7 +121,7 @@ export default function AdminDonasiTransactionDetail() {
return (
<BoxButtonOnFooter>
<ButtonCustom disabled>
{data?.DonasiMaster_StatusInvoice?.name}
{data?.DonasiMaster_StatusInvoice?.name}
</ButtonCustom>
</BoxButtonOnFooter>
);
@@ -140,7 +152,7 @@ export default function AdminDonasiTransactionDetail() {
})}
>
{_.startCase(
(data?.DonasiMaster_StatusInvoice?.name as any) || "-"
(data?.DonasiMaster_StatusInvoice?.name as any) || "-",
)}
</BadgeCustom>
)) ||
@@ -157,7 +169,7 @@ export default function AdminDonasiTransactionDetail() {
<ButtonCustom
onPress={() =>
router.push(
`/(application)/(image)/preview-image/${data?.imageId}`
`/(application)/(image)/preview-image/${data?.imageId}`,
)
}
>

View File

@@ -14,7 +14,11 @@ import {
} from "@/components";
import AdminBackButtonAntTitle from "@/components/_ShareComponent/Admin/BackButtonAntTitle";
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 { formatCurrencyDisplay } from "@/utils/formatCurrencyDisplay";
import pickFile from "@/utils/pickFile";
@@ -25,7 +29,7 @@ import Toast from "react-native-toast-message";
export default function AdminDonationDisbursementOfFunds() {
const { id } = useLocalSearchParams();
const { user } = useAuth();
const [data, setData] = React.useState<any | null>(null);
const [isLoading, setIsLoading] = React.useState(false);
@@ -40,7 +44,7 @@ export default function AdminDonationDisbursementOfFunds() {
useFocusEffect(
React.useCallback(() => {
onLoadData();
}, [id])
}, [id]),
);
const onLoadData = async () => {
@@ -94,6 +98,7 @@ export default function AdminDonationDisbursementOfFunds() {
const newData = {
...value,
authorId: user?.id,
imageId: imageId,
};

View File

@@ -7,15 +7,15 @@ import {
} from "@/components";
import AdminBackButtonAntTitle from "@/components/_ShareComponent/Admin/BackButtonAntTitle";
import AdminButtonReject from "@/components/_ShareComponent/Admin/ButtonReject";
import { useAuth } from "@/hooks/use-auth";
import { funUpdateStatusDonation } from "@/screens/Admin/Donation/funDonationUpdateStatus";
import {
apiAdminDonationDetailById
} from "@/service/api-admin/api-admin-donation";
import { apiAdminDonationDetailById } from "@/service/api-admin/api-admin-donation";
import { router, useFocusEffect, useLocalSearchParams } from "expo-router";
import React from "react";
import Toast from "react-native-toast-message";
export default function AdminDonationRejectInput() {
const { user } = useAuth();
const { id, status } = useLocalSearchParams();
const [data, setData] = React.useState<any | null>(null);
@@ -24,7 +24,7 @@ export default function AdminDonationRejectInput() {
useFocusEffect(
React.useCallback(() => {
onLoadData();
}, [id])
}, [id]),
);
const onLoadData = async () => {
@@ -48,11 +48,23 @@ export default function AdminDonationRejectInput() {
changeStatus: "publish" | "review" | "reject";
}) => {
try {
if (!user?.id) {
Toast.show({
type: "error",
text1: "User tidak ditemukan",
});
return;
}
setIsLoading(true);
const response = await funUpdateStatusDonation({
id: id as string,
changeStatus,
data: data,
data: {
senderId: user?.id as string,
catatan: data,
},
});
if (!response.success) {
@@ -61,7 +73,7 @@ export default function AdminDonationRejectInput() {
text1: "Report gagal",
});
return
return;
}
Toast.show({

View File

@@ -20,6 +20,7 @@ import AdminBackButtonAntTitle from "@/components/_ShareComponent/Admin/BackButt
import AdminButtonReject from "@/components/_ShareComponent/Admin/ButtonReject";
import AdminButtonReview from "@/components/_ShareComponent/Admin/ButtonReview";
import { GridSpan_4_8 } from "@/components/_ShareComponent/GridSpan_4_8";
import CustomSkeleton from "@/components/_ShareComponent/SkeletonCustom";
import ReportBox from "@/components/Box/ReportBox";
import { MainColor } from "@/constants/color-palet";
import { ICON_SIZE_BUTTON } from "@/constants/constans-value";
@@ -28,6 +29,7 @@ import {
apiAdminInvestmentDetailById,
} from "@/service/api-admin/api-admin-investment";
import { colorBadgeStatus } from "@/utils/colorBadge";
import { countDownAndCondition } from "@/utils/countDownAndCondition";
import { formatCurrencyDisplay } from "@/utils/formatCurrencyDisplay";
import { router, useFocusEffect, useLocalSearchParams } from "expo-router";
import _ from "lodash";
@@ -40,91 +42,41 @@ export default function AdminInvestmentDetail() {
const [data, setData] = React.useState<any | null>(null);
const [isLoading, setLoading] = React.useState(false);
const [remind, setRemind] = React.useState({
sisa: 0,
reminder: false,
});
useFocusEffect(
React.useCallback(() => {
onLoadData();
}, [id])
}, [id]),
);
const onLoadData = async () => {
try {
const response = await apiAdminInvestmentDetailById({ id: id as string });
// console.log("[GETONE INVEST]", JSON.stringify(response, null, 2));
if (response.success) {
setData(response.data);
const duration = response?.data?.MasterPencarianInvestor?.name;
const publishTime = response?.data?.countDown;
const countDown = countDownAndCondition({
duration: duration,
publishTime: publishTime
});
setRemind({
sisa: countDown.durationDay,
reminder: countDown.reminder,
});
}
} catch (error) {
console.log(error);
console.log("Error", error);
}
};
const listData = [
{
label: "Username",
value: (data && data?.author?.username) || "-",
},
{
label: "Judul",
value: (data && data?.title) || "-",
},
{
label: "Status",
value:
data && data?.MasterStatusInvestasi?.name ? (
<BadgeCustom
color={colorBadgeStatus({
status: data?.MasterStatusInvestasi?.name as string,
})}
>
{_.startCase(data?.MasterStatusInvestasi?.name as string)}
</BadgeCustom>
) : (
"-"
),
},
{
label: "Dana Dibutuhkan",
value: `Rp. ${
(data && data?.targetDana && formatCurrencyDisplay(data?.targetDana)) ||
"-"
}`,
},
{
label: "Harga Perlembar",
value: `Rp. ${
(data &&
data?.hargaLembar &&
formatCurrencyDisplay(data?.hargaLembar)) ||
"-"
}`,
},
{
label: "Total Lembar",
value:
(data &&
data?.totalLembar &&
formatCurrencyDisplay(data?.totalLembar)) ||
"-",
},
{
label: "ROI",
value: `${(data && data?.roi && data?.roi) || 0} %`,
},
{
label: "Pembagian Deviden",
value: (data && data?.MasterPembagianDeviden?.name) + " bulan" || "-",
},
{
label: "Jadwal Pembagian",
value: (data && data?.MasterPeriodeDeviden?.name) || "-",
},
{
label: "Pencarian Investor",
value: (data && data?.MasterPencarianInvestor?.name) + " hari" || "-",
},
];
const handlerSubmitPublish = async () => {
try {
setLoading(true);
@@ -134,7 +86,6 @@ export default function AdminInvestmentDetail() {
data: data,
});
// console.log("[GET ON INVEST]", JSON.stringify(response, null, 2));
if (!response.success) {
Toast.show({
type: "error",
@@ -164,6 +115,16 @@ export default function AdminInvestmentDetail() {
/>
);
if (!data) {
return (
<>
<ViewWrapper>
<CustomSkeleton height={200} />
</ViewWrapper>
</>
);
}
return (
<>
<ViewWrapper
@@ -177,8 +138,8 @@ export default function AdminInvestmentDetail() {
{status === "publish" && (
<BaseBox>
<ProgressCustom
label={data && `${data.progress}%` || "0%"}
value={data && data.progress || 0}
label={(data && `${data.progress}%`) || "0%"}
value={(data && data.progress) || 0}
size="lg"
/>
<Spacing />
@@ -187,7 +148,8 @@ export default function AdminInvestmentDetail() {
label={<TextCustom bold>Sisa Saham</TextCustom>}
value={
<TextCustom>
{data && formatCurrencyDisplay(data && data?.sisaLembar)} lembar
{data && formatCurrencyDisplay(data && data?.sisaLembar)}{" "}
lembar
</TextCustom>
}
/>
@@ -206,13 +168,15 @@ export default function AdminInvestmentDetail() {
<BaseBox>
<StackCustom>
<DummyLandscapeImage imageId={data?.imageId} />
{listData.map((item, i) => (
<GridSpan_4_8
key={i}
label={<TextCustom bold>{item.label}</TextCustom>}
value={<TextCustom>{item.value}</TextCustom>}
/>
))}
{listData({ data: data, reminder: remind.reminder })?.map(
(item, i) => (
<GridSpan_4_8
key={i}
label={<TextCustom bold>{item.label}</TextCustom>}
value={<TextCustom>{item.value}</TextCustom>}
/>
),
)}
</StackCustom>
</BaseBox>
@@ -230,7 +194,7 @@ export default function AdminInvestmentDetail() {
}
onPress={() => {
router.push(
`/(application)/(file)/${data?.prospektusFileId}`
`/(application)/(file)/${data?.prospektusFileId}`,
);
}}
>
@@ -259,7 +223,7 @@ export default function AdminInvestmentDetail() {
}
onPress={() => {
router.push(
`/(application)/(file)/${item?.fileId}`
`/(application)/(file)/${item?.fileId}`,
);
}}
>
@@ -299,8 +263,8 @@ export default function AdminInvestmentDetail() {
onReject={() => {
router.push(
`/admin/investment/${id}/reject-input?status=${_.lowerCase(
data?.MasterStatusInvestasi?.name
)}`
data?.MasterStatusInvestasi?.name,
)}`,
);
}}
/>
@@ -343,3 +307,67 @@ export default function AdminInvestmentDetail() {
</>
);
}
const listData = ({ data, reminder }: { data: any; reminder: boolean }) => [
{
label: "Username",
value: (data && data?.author?.username) || "-",
},
{
label: "Judul",
value: (data && data?.title) || "-",
},
{
label: "Status",
value:
data && data?.MasterStatusInvestasi?.name ? (
<BadgeCustom
color={colorBadgeStatus({
status: reminder ? "periode berakhir" : "publish",
})}
>
{reminder
? "Periode Berakhir"
: _.startCase(data?.MasterStatusInvestasi?.name as string)}
</BadgeCustom>
) : (
"-"
),
},
{
label: "Dana Dibutuhkan",
value: `Rp. ${
(data && data?.targetDana && formatCurrencyDisplay(data?.targetDana)) ||
"-"
}`,
},
{
label: "Harga Perlembar",
value: `Rp. ${
(data && data?.hargaLembar && formatCurrencyDisplay(data?.hargaLembar)) ||
"-"
}`,
},
{
label: "Total Lembar",
value:
(data && data?.totalLembar && formatCurrencyDisplay(data?.totalLembar)) ||
"-",
},
{
label: "ROI",
value: `${(data && data?.roi && data?.roi) || 0} %`,
},
{
label: "Pembagian Deviden",
value: (data && data?.MasterPembagianDeviden?.name) + " bulan" || "-",
},
{
label: "Jadwal Pembagian",
value: (data && data?.MasterPeriodeDeviden?.name) || "-",
},
{
label: "Pencarian Investor",
value: (data && data?.MasterPencarianInvestor?.name) + " hari" || "-",
},
];

View File

@@ -13,6 +13,7 @@ import AdminBackButtonAntTitle from "@/components/_ShareComponent/Admin/BackButt
import { GridSpan_4_8 } from "@/components/_ShareComponent/GridSpan_4_8";
import GridTwoView from "@/components/_ShareComponent/GridTwoView";
import { MainColor } from "@/constants/color-palet";
import { useAuth } from "@/hooks/use-auth";
import {
apiAdminInvestmentGetOneInvoiceById,
apiAdminInvestmentUpdateInvoice,
@@ -25,6 +26,7 @@ import { useCallback, useState } from "react";
import Toast from "react-native-toast-message";
export default function AdminInvestmentTransactionDetail() {
const { user } = useAuth();
const { id } = useLocalSearchParams();
const [data, setData] = useState<any | null>(null);
const [isLoading, setLoading] = useState<boolean>(false);
@@ -32,7 +34,7 @@ export default function AdminInvestmentTransactionDetail() {
useFocusEffect(
useCallback(() => {
onLoadData();
}, [id])
}, [id]),
);
const onLoadData = async () => {
@@ -40,7 +42,6 @@ export default function AdminInvestmentTransactionDetail() {
const response = await apiAdminInvestmentGetOneInvoiceById({
id: id as string,
});
// console.log("[RESPONSE]", JSON.stringify(response, null, 2));
if (response.success) {
setData(response.data);
}
@@ -92,7 +93,7 @@ export default function AdminInvestmentTransactionDetail() {
<ButtonCustom
onPress={() =>
router.push(
`/(application)/(image)/preview-image/${data?.imageId}`
`/(application)/(image)/preview-image/${data?.imageId}`,
)
}
>
@@ -109,6 +110,13 @@ export default function AdminInvestmentTransactionDetail() {
}: {
category: "accept" | "deny";
}) => {
if (!user?.id) {
Toast.show({
type: "error",
text1: "Gagal update status transaksi",
});
return;
}
try {
setLoading(true);
const response = await apiAdminInvestmentUpdateInvoice({
@@ -117,11 +125,10 @@ export default function AdminInvestmentTransactionDetail() {
data: {
investasiId: data?.investasiId,
lembarTerbeli: data?.lembarTerbeli,
senderId: user?.id as any,
},
});
// console.log("[RESPONSE SUBMIT]", JSON.stringify(response, null, 2));
if (!response.success) {
Toast.show({
type: "error",
@@ -153,6 +160,7 @@ export default function AdminInvestmentTransactionDetail() {
styleRight={{ paddingLeft: 10 }}
leftIcon={
<ButtonCustom
disabled={isLoading}
isLoading={isLoading}
backgroundColor={MainColor.red}
textColor="white"
@@ -175,6 +183,7 @@ export default function AdminInvestmentTransactionDetail() {
}
rightIcon={
<ButtonCustom
disabled={isLoading}
isLoading={isLoading}
onPress={() => {
AlertDefaultSystem({
@@ -198,8 +207,8 @@ export default function AdminInvestmentTransactionDetail() {
} else if (data?.StatusInvoice?.name === "Gagal") {
return (
<>
<ButtonCustom textColor="red" onPress={() => router.back()}>
Gagal
<ButtonCustom disabled onPress={() => router.back()}>
Transaksi telah gagal
</ButtonCustom>
</>
);

View File

@@ -22,8 +22,6 @@ import { Divider } from "react-native-paper";
export default function AdminInvestmentStatus() {
const { status } = useLocalSearchParams();
console.log("[STATUS]", status);
const [listData, setListData] = React.useState<any[] | null>(null);
const [loadData, setLoadingData] = React.useState(false);
const [search, setSearch] = React.useState("");
@@ -41,7 +39,7 @@ export default function AdminInvestmentStatus() {
category: status as "publish" | "review" | "reject",
search,
});
console.log("[LIST DATA]", JSON.stringify(response, null, 2));
if (response.success) {
setListData(response.data);
}

View File

@@ -10,14 +10,10 @@ import {
import AdminBackButtonAntTitle from "@/components/_ShareComponent/Admin/BackButtonAntTitle";
import GridTwoView from "@/components/_ShareComponent/GridTwoView";
import { useAuth } from "@/hooks/use-auth";
import { routeUser } from "@/lib/routeApp";
import {
apiAdminUserAccessGetById,
apiAdminUserAccessUpdateStatus,
} from "@/service/api-admin/api-admin-user-access";
import {
apiNotificationsSendById
} from "@/service/api-notifications";
import { router, useFocusEffect, useLocalSearchParams } from "expo-router";
import { useCallback, useState } from "react";
import Toast from "react-native-toast-message";
@@ -70,20 +66,6 @@ export default function AdminUserAccessDetail() {
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();
} catch (error) {
console.log("[ERROR UPDATE STATUS]", error);

View File

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

View File

@@ -30,7 +30,6 @@ export default function AvatarComp({
href = `/(application)/(image)/preview-image/${fileId}`,
}: AvatarCompProps) {
const dimension = sizeMap[size];
const avatarImage = () => {
return (
<Avatar.Image
@@ -52,8 +51,9 @@ export default function AvatarComp({
<TouchableOpacity
activeOpacity={0.9}
onPress={
href && fileId ? () => router.navigate(href as any) : onPress
href || fileId ? () => router.navigate(href as any) : onPress
}
disabled={!fileId}
>
{avatarImage()}
</TouchableOpacity>

View File

@@ -0,0 +1,33 @@
import { Modal, View } from "react-native";
export default function ModalReactNative({
children,
isVisible,
}: {
children: React.ReactNode;
isVisible: boolean;
}) {
return (
<Modal
animationType="slide"
backdropColor={"rgba(0, 0, 0, 0.5)"}
visible={isVisible}
>
<View
style={{
flex: 1,
justifyContent: "center",
alignItems: "center",
backgroundColor: "rgba(0, 0, 0, 0.5)",
// margin: 10,
marginBlock: 30,
padding: 10,
borderRadius: 10,
paddingTop: 30
}}
>
{children}
</View>
</Modal>
);
}

View File

@@ -19,11 +19,12 @@ export {
PADDING_SMALL,
PADDING_MEDIUM,
PADDING_LARGE,
PAGINATION_DEFAULT_TAKE
};
// OS Height
const OS_ANDROID_HEIGHT = 115
const OS_IOS_HEIGHT = 70
const OS_IOS_HEIGHT = 90
const OS_HEIGHT = Platform.OS === "ios" ? OS_IOS_HEIGHT : OS_ANDROID_HEIGHT
// Text Size
@@ -51,3 +52,5 @@ const PADDING_SMALL = 12
const PADDING_MEDIUM = 16
const PADDING_LARGE = 20
// Pagination
const PAGINATION_DEFAULT_TAKE = 10;

View File

@@ -21,7 +21,7 @@ type AuthContextType = {
isAuthenticated: boolean;
isAdmin: boolean;
isUserActive: boolean;
loginWithNomor: (nomor: string) => Promise<void>;
loginWithNomor: (nomor: string) => Promise<boolean>;
validateOtp: (nomor: string) => Promise<any>;
logout: () => Promise<void>;
registerUser: (userData: {
@@ -30,12 +30,15 @@ type AuthContextType = {
termsOfServiceAccepted: boolean;
}) => Promise<void>;
userData: (token: string) => Promise<any>;
acceptedTerms: (nomor: string) => Promise<any>;
acceptedTerms: (
nomor: string,
onSetModalVisible: (visible: boolean) => void,
) => Promise<any>;
};
// --- Create Context ---
export const AuthContext = createContext<AuthContextType | undefined>(
undefined
undefined,
);
export const AuthProvider = ({ children }: { children: React.ReactNode }) => {
@@ -79,30 +82,12 @@ export const AuthProvider = ({ children }: { children: React.ReactNode }) => {
const response = await apiLogin({ nomor: nomor });
console.log("[RESPONSE AUTH]", JSON.stringify(response, null, 2));
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;
} else {
router.push(`/eula?nomor=${nomor}`);
return;
}
if (response.success && response.isAcceptTerms) {
await AsyncStorage.setItem("kode_otp", response.kodeId);
router.push(`/verification?nomor=${nomor}`);
return true;
} else {
router.push(`/eula?nomor=${nomor}`);
// Toast.show({
// type: "info",
// text1: "Info",
// text2: "Silahkan mendaftar",
// });
return;
return false;
}
} catch (error: any) {
throw new Error(error.response?.data?.message || "Gagal kirim OTP");
@@ -158,7 +143,7 @@ export const AuthProvider = ({ children }: { children: React.ReactNode }) => {
} catch (error: any) {
console.log("Error validasi otp >>", (error as Error).message || error);
throw new Error(
error.response?.data?.message || "OTP salah atau user tidak ditemukan"
error.response?.data?.message || "OTP salah atau user tidak ditemukan",
);
} finally {
setIsLoading(false);
@@ -187,7 +172,7 @@ export const AuthProvider = ({ children }: { children: React.ReactNode }) => {
} catch (error: any) {
console.log(
"[LOAD USER DATA]",
error.response?.data?.message + "user" || "Gagal mengambil data user"
error.response?.data?.message + "user" || "Gagal mengambil data user",
);
} finally {
setIsLoading(false);
@@ -261,28 +246,25 @@ export const AuthProvider = ({ children }: { children: React.ReactNode }) => {
}
};
const acceptedTerms = async (nomor: string) => {
// --- 6. Accept Terms ---
const acceptedTerms = async (
nomor: string,
onSetModalVisible: (visible: boolean) => void,
) => {
try {
setIsLoading(true);
const response = await apiUpdatedTermCondition({ nomor: nomor });
if (response.success) {
router.replace(`/verification?nomor=${nomor}`);
return `/verification?nomor=${nomor}`;
} else {
if (response.status === 404) {
router.replace(`/register?nomor=${nomor}`);
} else {
Toast.show({
type: "error",
text1: "Error",
text2: response.message,
});
}
return `/register?nomor=${nomor}`;
}
} catch (error) {
console.log("Error accept terms", error);
} finally {
setIsLoading(false);
onSetModalVisible(false);
}
};

View File

@@ -18,7 +18,7 @@ android: bunx expo prebuild --platform android
adb devices : cek device yang terhubung
Note: izinkan perangkat dulu agar statusnya tidak unauthorized
adb install android/app/build/outputs/apk/debug/app-debug.apk : install apk ke device
adb install android/app/build/outputs/apk/debug/app-debug.apk : install apk ke device / emulator
Note:
Gunakan flag -s (serial) di perintah adb untuk menentukan target
adb -s <0G52319V261040B2 ini adalah id nya> install android/app/build/outputs/apk/debug/app-debug.apk

View File

@@ -0,0 +1,22 @@
<!-- Start Penerapan Pagination -->
File utama: screens/Notification/ScreenNotification.tsx
Fun fecth: apiGetNotificationsById
File fetch: service/api-notifications.ts
File komponen wrapper: components/_ShareComponent/NewWrapper.tsx
Terapkan pagination pada file "File utama"
Analisa juga file "File utama" , jika belum menggunakan NewWrapper pada file "File komponen wrapper" , maka terapkan juga dan ganti wrapper lama yaitu komponen ViewWrapper
Komponen pagination yang digunaka berada pada file hooks/use-pagination.tsx dan helpers/paginationHelpers.tsx
Perbaiki fetch "Fun fecth" , pada file "File fetch"
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
View File

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

View File

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

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

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

View File

@@ -39,7 +39,7 @@
</dict>
</array>
<key>CFBundleVersion</key>
<string>19</string>
<string>20</string>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
<key>LSMinimumSystemVersion</key>

View File

@@ -1,4 +1,5 @@
import { apiAdminDonationUpdateStatus } from "@/service/api-admin/api-admin-donation";
import { typeRejectedData } from "@/types/type-collect-other";
export const funUpdateStatusDonation = async ({
id,
@@ -7,7 +8,7 @@ export const funUpdateStatusDonation = async ({
}: {
id: string;
changeStatus: "publish" | "review" | "reject";
data?: string;
data?: typeRejectedData;
}) => {
try {
const response = await apiAdminDonationUpdateStatus({

View File

@@ -0,0 +1,286 @@
// app/syarat-dan-ketentuan.tsx
import {
View,
Text,
ScrollView,
TouchableOpacity,
StyleSheet,
} from "react-native";
import { useState, useRef } from "react";
import { router, useLocalSearchParams, useRouter } from "expo-router";
import { SafeAreaView } from "react-native-safe-area-context";
import { AccentColor, MainColor } from "@/constants/color-palet";
import { useAuth } from "@/hooks/use-auth";
import Toast from "react-native-toast-message";
export default function EULASection({
nomor,
onSetModalVisible,
setLoadingTerm,
}: {
nomor: string;
onSetModalVisible: (visible: boolean) => void;
setLoadingTerm: (loading: boolean) => void;
}) {
const { acceptedTerms } = useAuth();
const [isLoading, setIsLoading] = useState<boolean>(false);
const [isAtBottom, setIsAtBottom] = useState(false);
const scrollViewRef = useRef<ScrollView>(null);
const handleScroll = (event: any) => {
const { layoutMeasurement, contentOffset, contentSize } = event.nativeEvent;
const paddingToBottom = 20;
const isCloseToBottom =
layoutMeasurement.height + contentOffset.y >=
contentSize.height - paddingToBottom;
setIsAtBottom(isCloseToBottom);
};
const handleAccept = async () => {
// console.log("Accept terms", nomor);
// onSetModalVisible(false);
try {
if (!isAtBottom) return;
setIsLoading(true);
const responseAccept = await acceptedTerms(
nomor as string,
onSetModalVisible,
);
console.log("Accept terms", responseAccept);
setLoadingTerm(true);
setTimeout(() => {
router.replace(responseAccept);
}, 500);
} catch (error) {
console.log("Error accept terms", error);
Toast.show({
type: "error",
text1: "Error",
text2: "Terjadi kesalahan saat menerima syarat dan ketentuan",
});
} finally {
setIsLoading(false);
}
};
return (
<SafeAreaView edges={["bottom"]} style={styles.container}>
<Text style={styles.title}>
Syarat & Ketentuan Penggunaan HIPMI Badung Connect
</Text>
<ScrollView
ref={scrollViewRef}
onScroll={handleScroll}
scrollEventThrottle={16}
style={styles.scrollView}
contentContainerStyle={styles.scrollContent}
>
<Text style={styles.paragraph}>
Dengan menggunakan aplikasi{" "}
<Text style={styles.bold}>HIPMI Badung Connect</Text> (Aplikasi),
Anda setuju untuk mematuhi dan terikat oleh syarat dan ketentuan
berikut. Jika Anda tidak setuju dengan ketentuan ini, harap jangan
gunakan Aplikasi.
</Text>
<Text style={styles.heading}>1. Definisi</Text>
<Text style={styles.paragraph}>
<Text style={styles.bold}>HIPMI Badung Connect</Text> adalah platform
digital resmi untuk anggota Himpunan Pengusaha Muda Indonesia (HIPMI)
Kabupaten Badung, yang bertujuan memfasilitasi jaringan, kolaborasi,
dan pertumbuhan bisnis para pengusaha muda.
</Text>
<Text style={styles.heading}>2. Larangan Konten Tidak Pantas</Text>
<Text style={styles.paragraph}>
Anda <Text style={styles.bold}>dilarang keras</Text> memposting,
mengirim, membagikan, atau mengunggah konten apa pun yang mengandung:
</Text>
<View style={styles.list}>
<Text style={styles.listItem}>
Ujaran kebencian, diskriminasi, atau konten SARA (Suku, Agama,
Ras, Antar-golongan)
</Text>
<Text style={styles.listItem}>
Pornografi, konten seksual eksplisit, atau gambar tidak senonoh
</Text>
<Text style={styles.listItem}>
Ancaman, pelecehan, bullying, atau perilaku melecehkan
</Text>
<Text style={styles.listItem}>
Informasi palsu, hoaks, spam, atau konten menyesatkan
</Text>
<Text style={styles.listItem}>
Konten ilegal, melanggar hukum, atau melanggar hak kekayaan
intelektual pihak lain
</Text>
<Text style={styles.listItem}>
Promosi narkoba, perjudian, atau aktivitas ilegal lainnya
</Text>
</View>
<Text style={styles.heading}>3. Tanggung Jawab Pengguna</Text>
<Text style={styles.paragraph}>
Anda bertanggung jawab penuh atas setiap konten yang Anda unggah atau
bagikan melalui fitur-fitur berikut:
</Text>
<View style={styles.list}>
<Text style={styles.listItem}> Profil (bio, foto, portofolio)</Text>
<Text style={styles.listItem}> Forum diskusi</Text>
<Text style={styles.listItem}> Chat pribadi atau grup</Text>
<Text style={styles.listItem}>
Lowongan kerja, investasi, dan donasi
</Text>
</View>
<Text style={styles.paragraph}>
Konten yang melanggar ketentuan ini dapat dihapus kapan saja tanpa
pemberitahuan.
</Text>
<Text style={styles.heading}>4. Tindakan terhadap Pelanggaran</Text>
<Text style={styles.paragraph}>
Jika kami menerima laporan atau menemukan konten yang melanggar
ketentuan ini, kami akan:
</Text>
<View style={styles.list}>
<Text style={styles.listItem}>
Segera menghapus konten tersebut
</Text>
<Text style={styles.listItem}>
Memberikan peringatan atau memblokir akun pengguna
</Text>
<Text style={styles.listItem}>
Dalam kasus berat, melaporkan ke pihak berwajib sesuai hukum yang
berlaku
</Text>
</View>
<Text style={styles.paragraph}>
Tim kami berkomitmen untuk menanggapi laporan konten tidak pantas{" "}
<Text style={styles.bold}>dalam waktu 24 jam</Text>.
</Text>
<Text style={styles.heading}>5. Mekanisme Pelaporan</Text>
<Text style={styles.paragraph}>
Anda dapat melaporkan konten atau pengguna yang mencurigakan melalui:
</Text>
<View style={styles.list}>
<Text style={styles.listItem}>
Tombol <Text style={styles.bold}>Laporkan</Text> di setiap
posting forum atau pesan chat
</Text>
<Text style={styles.listItem}>
Tombol <Text style={styles.bold}>Blokir Pengguna</Text> di
profil pengguna
</Text>
</View>
<Text style={styles.paragraph}>
Setiap laporan akan ditangani secara rahasia dan segera.
</Text>
<Text style={styles.heading}>6. Perubahan Ketentuan</Text>
<Text style={styles.paragraph}>
Kami berhak memperbarui Syarat & Ketentuan ini sewaktu-waktu. Versi
terbaru akan dipublikasikan di halaman ini dengan tanggal revisi yang
diperbarui.
</Text>
<Text style={styles.heading}>7. Kontak</Text>
<Text style={styles.paragraph}>
Jika Anda memiliki pertanyaan tentang ketentuan ini, silakan hubungi
kami di:{"\n"}
<Text style={[styles.bold, { color: "#1E90FF" }]}>
bip.baliinteraktifperkasa@gmail.com
</Text>
</Text>
<Text style={styles.footer}>
© 2026 Bali Interaktif Perkasa. All rights reserved.
</Text>
</ScrollView>
<TouchableOpacity
onPress={handleAccept}
disabled={!isAtBottom || isLoading}
style={[styles.button, { opacity: !isAtBottom || isLoading ? 0.6 : 1 }]}
>
<Text style={styles.buttonText}>
{isLoading ? "Menyimpan..." : "Saya Setuju"}
</Text>
</TouchableOpacity>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: MainColor.darkblue,
padding: 16,
},
title: {
fontSize: 20,
fontWeight: "bold",
textAlign: "center",
marginBottom: 16,
color: MainColor.white,
},
scrollView: {
flex: 1,
marginBottom: 20,
},
scrollContent: {
paddingBottom: 30,
},
heading: {
fontSize: 16,
fontWeight: "600",
marginTop: 16,
marginBottom: 8,
color: MainColor.white,
},
paragraph: {
fontSize: 14,
lineHeight: 22,
color: MainColor.white,
marginBottom: 12,
},
bold: {
fontWeight: "600",
},
list: {
marginLeft: 8,
marginBottom: 12,
},
listItem: {
fontSize: 14,
lineHeight: 22,
color: MainColor.white,
marginBottom: 6,
},
footer: {
fontSize: 12,
color: MainColor.white,
textAlign: "center",
marginTop: 20,
paddingTop: 10,
borderTopWidth: 2,
borderTopColor: AccentColor.blue,
},
button: {
backgroundColor: MainColor.yellow,
paddingVertical: 14,
borderRadius: 8,
alignItems: "center",
width: 200,
alignSelf: "center",
},
buttonText: {
color: "#fff",
fontSize: 16,
fontWeight: "600",
},
});

View File

@@ -12,25 +12,6 @@ import { SafeAreaView } from "react-native-safe-area-context";
import { AccentColor, MainColor } from "@/constants/color-palet";
import { useAuth } from "@/hooks/use-auth";
// Ganti dengan API call ke backend Anda
// const acceptEula = async (): Promise<boolean> => {
// try {
// const response = await fetch("/api/user/update-eula", {
// method: "PATCH",
// headers: { "Content-Type": "application/json" },
// credentials: "include",
// body: JSON.stringify({
// eulaAcceptedAt: new Date().toISOString(),
// eulaVersion: "2026-01-v1", // sesuaikan versi Anda
// }),
// });
// return response.ok;
// } catch (error) {
// console.error("Gagal menyimpan persetujuan EULA:", error);
// return false;
// }
// };
export default function EULAView() {
const { acceptedTerms } = useAuth();
const { nomor } = useLocalSearchParams();
@@ -52,7 +33,7 @@ export default function EULAView() {
if (!isAtBottom) return;
setIsLoading(true);
await acceptedTerms(nomor as string);
// await acceptedTerms(nomor as string);
} catch (error) {
console.log("Error accept terms", error);
} finally {

View File

@@ -1,25 +1,30 @@
import { NewWrapper } from "@/components";
import ButtonCustom from "@/components/Button/ButtonCustom";
import ModalReactNative from "@/components/Modal/ModalReactNative";
import Spacing from "@/components/_ShareComponent/Spacing";
import ViewWrapper from "@/components/_ShareComponent/ViewWrapper";
import { MainColor } from "@/constants/color-palet";
import { useAuth } from "@/hooks/use-auth";
import { apiVersion } from "@/service/api-config";
import { apiVersion, BASE_URL } from "@/service/api-config";
import { GStyles } from "@/styles/global-styles";
import { openBrowser } from "@/utils/openBrower";
import versionBadge from "@/utils/viersionBadge";
import VersionBadge from "@/utils/viersionBadge";
import { Redirect } from "expo-router";
import { useEffect, useState } from "react";
import { RefreshControl, Text, View } from "react-native";
import PhoneInput, { ICountry } from "react-native-international-phone-number";
import Toast from "react-native-toast-message";
import EULASection from "./EULASection";
export default function LoginView() {
const url = BASE_URL;
const [version, setVersion] = useState<string>("");
const [selectedCountry, setSelectedCountry] = useState<null | ICountry>(null);
const [inputValue, setInputValue] = useState<string>("");
const [loading, setLoading] = useState<boolean>(false);
const [refreshing, setRefreshing] = useState<boolean>(false);
const [modalVisible, setModalVisible] = useState(false);
const [numberToEULA, setNumberToEULA] = useState<string>("");
const [loadingTerm, setLoadingTerm] = useState<boolean>(false);
const { loginWithNomor, token, isAdmin, isUserActive } = useAuth();
@@ -87,7 +92,13 @@ export default function LoginView() {
try {
setLoading(true);
await loginWithNomor(realNumber);
const loginRes = await loginWithNomor(realNumber);
if (!loginRes) {
setModalVisible(true);
}
setNumberToEULA(realNumber);
} catch (error) {
console.log("Error login", error);
Toast.show({
@@ -98,30 +109,6 @@ export default function LoginView() {
} finally {
setLoading(false);
}
// try {
// setLoading(true);
// // const response = await apiLogin({ nomor: realNumber });
// const response = await loginWithNomor(realNumber);
// console.log("[RESPONSE]", response);
// Toast.show({
// type: "success",
// text1: "Sukses",
// text2: "Kode OTP berhasil dikirim",
// });
// // router.navigate(`/verification?nomor=${realNumber}`);
// } catch (error) {
// console.log("Error login", error);
// Toast.show({
// type: "error",
// text1: "Error",
// text2: error as string,
// });
// } finally {
// setLoading(false);
// }
}
if (token && token !== "" && !isUserActive) {
@@ -182,14 +169,42 @@ export default function LoginView() {
<Spacing />
<ButtonCustom onPress={handleLogin} isLoading={loading}>
<ButtonCustom
onPress={handleLogin}
disabled={loadingTerm}
isLoading={loading || loadingTerm}
>
Login
</ButtonCustom>
<Spacing />
{/* <ButtonCustom onPress={() => router.navigate("/(application)/coba")}>
Coba
</ButtonCustom> */}
<Spacing height={50} />
<Text
style={{ ...GStyles.textLabel, textAlign: "center", fontSize: 12 }}
>
Dengan menggunakan aplikasi ini, Anda telah menyetujui{" "}
<Text
style={{
color: MainColor.yellow,
textDecorationLine: "underline",
}}
onPress={() => {
const toUrl = `${url}/terms-of-service.html`;
openBrowser(toUrl);
}}
>
Syarat & Ketentuan
</Text>{" "}
dan seluruh kebijakan privasi yang berlaku.
</Text>
</View>
<ModalReactNative isVisible={modalVisible}>
<EULASection
nomor={numberToEULA || ""}
onSetModalVisible={setModalVisible}
setLoadingTerm={setLoadingTerm}
/>
</ModalReactNative>
</NewWrapper>
);
}

View 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>
</>
);
}

View File

@@ -0,0 +1,320 @@
import {
BoxButtonOnFooter,
ButtonCustom,
DrawerCustom,
LoaderCustom,
NewWrapper,
Spacing,
StackCustom,
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 ? (
<StackCustom>
<CustomSkeleton height={200} />
<CustomSkeleton height={100} />
</StackCustom>
) : (
<>
{/* 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 }) =>(
<Forum_CommentarBoxSection
key={item.id}
data={item}
onSetData={(value) => {
setCommentId(value.setCommentId);
setOpenDrawerCommentar(value.setOpenDrawer);
setCommentAuthorId(value.setCommentAuthorId);
}}
/>
)
// !data || !commentPagination.listData ? (
// // <ListSkeletonComponent height={120} />
// <LoaderCustom />
// ) : (
// <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>
</>
);
}

View 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={{ paddingTop: 8 }}>
<SearchInput
placeholder="Cari topik diskusi"
onChangeText={_.debounce((text) => setSearch(text), 500)}
/>
</View>
}
floatingButton={
<FloatingButton
onPress={() =>
router.navigate("/(application)/(user)/forum/create")
}
/>
}
listData={pagination.listData}
renderItem={renderForumItem}
refreshControl={
<RefreshControl
tintColor={MainColor.yellow}
colors={[MainColor.yellow]}
refreshing={pagination.refreshing}
onRefresh={pagination.onRefresh}
/>
}
onEndReached={pagination.loadMore}
// ListHeaderComponent={ListHeaderComponent}
ListEmptyComponent={ListEmptyComponent}
ListFooterComponent={ListFooterComponent}
/>
</>
);
}

View File

@@ -15,38 +15,49 @@ import NoDataText from "@/components/_ShareComponent/NoDataText";
import CustomSkeleton from "@/components/_ShareComponent/SkeletonCustom";
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_BoxDetailSection from "@/screens/Forum/DiscussionBoxSection";
import { apiForumGetAll } from "@/service/api-client/api-forum";
import { apiUser } from "@/service/api-client/api-user";
import { router, useLocalSearchParams } from "expo-router";
import _ from "lodash";
import { useCallback, useEffect, useState } from "react";
import { useEffect, useState } from "react";
import { RefreshControl, View } from "react-native";
const PAGE_SIZE = 5;
export default function View_Forumku2() {
const { id } = useLocalSearchParams();
const { user } = useAuth();
const [listData, setListData] = useState<any[]>([]);
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);
// 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(() => {
onLoadDataProfile(id as string);
}, [id]);
useEffect(() => {
setPage(1);
setListData([]);
setHasMore(true);
fetchData(1, true);
}, [user?.id]);
const onLoadDataProfile = async (id: string) => {
try {
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 = () => (
<>
<CenterCustom>
@@ -144,39 +107,16 @@ export default function View_Forumku2() {
/>
);
// Skeleton List (untuk initial load)
const SkeletonListComponent = () => (
<View style={{ flex: 1 }}>
<StackCustom>
{Array.from({ length: 5 }).map((_, i) => (
<CustomSkeleton height={200} key={i} />
))}
</StackCustom>
</View>
);
// 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;
// Generate pagination components using helper
const { ListEmptyComponent, ListFooterComponent } = createPaginationComponents({
loading: pagination.loading,
refreshing: pagination.refreshing,
listData: pagination.listData,
isInitialLoad: pagination.isInitialLoad,
emptyMessage: "Tidak ada postingan",
skeletonCount: 5,
skeletonHeight: 200,
});
return (
<>
@@ -190,7 +130,7 @@ export default function View_Forumku2() {
/>
)
}
listData={listData}
listData={pagination.listData}
renderItem={renderList}
refreshControl={
<RefreshControl
@@ -199,16 +139,14 @@ export default function View_Forumku2() {
// Android
colors={[MainColor.yellow]}
progressBackgroundColor={MainColor.yellow}
refreshing={refreshing}
onRefresh={onRefresh}
refreshing={pagination.refreshing}
onRefresh={pagination.onRefresh}
/>
}
onEndReached={loadMore}
onEndReached={pagination.loadMore}
ListHeaderComponent={randerHeaderComponent()}
ListFooterComponent={ListFooterComponent}
ListEmptyComponent={
loading && _.isEmpty(listData) ? <SkeletonListComponent /> : <EmptyComponent />
}
ListEmptyComponent={ListEmptyComponent}
/>
</>
);

View File

@@ -0,0 +1,249 @@
/* eslint-disable react-hooks/exhaustive-deps */
import {
AlertDefaultSystem,
BackButton,
BaseBox,
DrawerCustom,
MenuDrawerDynamicGrid,
NewWrapper,
ScrollableCustom,
StackCustom,
TextCustom,
} from "@/components";
import { IconDot } from "@/components/_Icon/IconComponent";
import ListSkeletonComponent from "@/components/_ShareComponent/ListSkeletonComponent";
import NoDataText from "@/components/_ShareComponent/NoDataText";
import { AccentColor, MainColor } from "@/constants/color-palet";
import { ICON_SIZE_SMALL, PAGINATION_DEFAULT_TAKE } from "@/constants/constans-value";
import { useAuth } from "@/hooks/use-auth";
import { useNotificationStore } from "@/hooks/use-notification-store";
import { usePagination } from "@/hooks/use-pagination";
import { createPaginationComponents } from "@/helpers/paginationHelpers";
import { apiGetNotificationsById } from "@/service/api-notifications";
import { listOfcategoriesAppNotification } from "@/types/type-notification-category";
import { formatChatTime } from "@/utils/formatChatTime";
import { Ionicons } from "@expo/vector-icons";
import { router, Stack, useFocusEffect, useLocalSearchParams } from "expo-router";
import _ from "lodash";
import { useCallback, useState } from "react";
import { RefreshControl, View } from "react-native";
const selectedCategory = (value: string) => {
const category = listOfcategoriesAppNotification.find(
(c) => c.value === value
);
return category?.label;
};
const fixPath = ({
deepLink,
categoryApp,
}: {
deepLink: string;
categoryApp: string;
}) => {
if (categoryApp === "OTHER") {
return deepLink;
}
const separator = deepLink.includes("?") ? "&" : "?";
const fixedPath = `${deepLink}${separator}from=notifications&category=${_.lowerCase(
categoryApp
)}`;
console.log("Fix Path", fixedPath);
return fixedPath;
};
const BoxNotification = ({
data,
activeCategory,
}: {
data: any;
activeCategory: string | null;
}) => {
// console.log("DATA NOTIFICATION", JSON.stringify(data, null, 2));
const { markAsRead } = useNotificationStore();
return (
<>
<BaseBox
backgroundColor={data.isRead ? AccentColor.darkblue : AccentColor.blue}
onPress={() => {
// console.log(
// "Notification >",
// selectedCategory(activeCategory as string)
// );
const newPath = fixPath({
deepLink: data.deepLink,
categoryApp: data.kategoriApp,
});
router.navigate(newPath as any);
selectedCategory(activeCategory as string);
if (!data.isRead) {
markAsRead(data.id);
}
}}
>
<StackCustom>
<TextCustom truncate={2} bold>
{data.title}
</TextCustom>
<TextCustom truncate={2}>{data.pesan}</TextCustom>
<TextCustom size="small" color="gray">
{formatChatTime(data.createdAt)}
</TextCustom>
</StackCustom>
</BaseBox>
</>
);
};
export default function ScreenNotification() {
const { user } = useAuth();
const { category } = useLocalSearchParams<{ category?: string }>();
const [activeCategory, setActiveCategory] = useState<string | null>(
category || "event"
);
const [openDrawer, setOpenDrawer] = useState(false);
const { markAsReadAll } = useNotificationStore();
// Initialize pagination for notifications
const pagination = usePagination({
fetchFunction: async (page) => {
return await apiGetNotificationsById({
id: user?.id as string,
category: activeCategory as any,
page: String(page), // API expects string
});
},
pageSize: PAGINATION_DEFAULT_TAKE,
dependencies: [activeCategory],
});
useFocusEffect(
useCallback(() => {
// Reset and load first page when category changes
pagination.reset();
pagination.onRefresh();
}, [activeCategory])
);
const handlePress = (item: any) => {
setActiveCategory(item.value);
// Reset and load first page when category changes
pagination.reset();
pagination.onRefresh();
};
// Render individual notification item
const renderItem = ({ item }: { item: any }) => (
<View key={item.id}>
<BoxNotification
data={item}
activeCategory={activeCategory as any}
/>
</View>
);
// Generate pagination components using helper
const { ListEmptyComponent, ListFooterComponent } = createPaginationComponents({
loading: pagination.loading,
refreshing: pagination.refreshing,
listData: pagination.listData,
isInitialLoad: pagination.isInitialLoad,
emptyMessage: "Belum ada notifikasi",
skeletonCount: 5,
skeletonHeight: 100,
});
return (
<>
<Stack.Screen
options={{
title: "Notifikasi",
headerLeft: () => <BackButton />,
headerRight: () => (
<IconDot
color={MainColor.yellow}
onPress={() => setOpenDrawer(true)}
/>
),
}}
/>
<NewWrapper
headerComponent={
<ScrollableCustom
data={listOfcategoriesAppNotification.map((e, i) => ({
id: i,
label: e.label,
value: e.value,
}))}
onButtonPress={handlePress}
activeId={activeCategory as string}
/>
}
listData={pagination.listData}
renderItem={renderItem}
refreshControl={
<RefreshControl
refreshing={pagination.refreshing}
onRefresh={pagination.onRefresh}
/>
}
onEndReached={pagination.loadMore}
ListFooterComponent={ListFooterComponent}
ListEmptyComponent={ListEmptyComponent}
/>
<DrawerCustom
isVisible={openDrawer}
closeDrawer={() => setOpenDrawer(false)}
height={"auto"}
>
<MenuDrawerDynamicGrid
data={[
{
label: "Tandai Semua Dibaca",
value: "read-all",
icon: (
<Ionicons
name="reader-outline"
size={ICON_SIZE_SMALL}
color={MainColor.white}
/>
),
path: "",
},
]}
onPressItem={(item: any) => {
console.log("Item", item.value);
if (item.value === "read-all") {
AlertDefaultSystem({
title: "Tandai Semua Dibaca",
message:
"Apakah Anda yakin ingin menandai semua notifikasi dibaca?",
textLeft: "Batal",
textRight: "Ya",
onPressRight: () => {
markAsReadAll(user?.id as any);
// Reset and refresh data after marking all as read
pagination.reset();
pagination.onRefresh();
setOpenDrawer(false);
},
});
}
}}
/>
</DrawerCustom>
</>
);
}

View File

@@ -1,5 +1,5 @@
import { BaseBox, Grid, TextCustom } from "@/components";
import { MainColor } from "@/constants/color-palet";
import { AccentColor, MainColor } from "@/constants/color-palet";
import { ICON_SIZE_SMALL } from "@/constants/constans-value";
import { Ionicons } from "@expo/vector-icons";
import { router } from "expo-router";
@@ -8,7 +8,7 @@ export default function Portofolio_BoxView({ data }: { data: any }) {
return (
<>
<BaseBox
style={{ backgroundColor: MainColor.darkblue }}
style={{ backgroundColor: AccentColor.blue}}
onPress={() => {
router.push(`/portofolio/${data?.id}`);
}}

View File

@@ -0,0 +1,74 @@
import { NewWrapper, TextCustom } from "@/components";
import CustomSkeleton from "@/components/_ShareComponent/SkeletonCustom";
import { MainColor } from "@/constants/color-palet";
import { usePagination } from "@/hooks/use-pagination";
import { createPaginationComponents } from "@/helpers/paginationHelpers";
import { apiGetPortofolio } from "@/service/api-client/api-portofolio";
import { useFocusEffect, useLocalSearchParams } from "expo-router";
import { useCallback } from "react";
import { RefreshControl } from "react-native";
import Portofolio_BoxView from "./BoxPortofolioView";
import NoDataText from "@/components/_ShareComponent/NoDataText";
import { PAGINATION_DEFAULT_TAKE } from "@/constants/constans-value";
export default function ViewListPortofolio() {
const { id } = useLocalSearchParams();
// Initialize pagination for portfolio items
const pagination = usePagination({
fetchFunction: async (page) => {
return await apiGetPortofolio({
id: id as string,
page: String(page) // API expects string
});
// return response.data;
},
pageSize: PAGINATION_DEFAULT_TAKE,
dependencies: [id],
});
useFocusEffect(
useCallback(() => {
// Reset and load first page when id changes
pagination.reset();
pagination.onRefresh();
}, [id]),
);
// Render individual portfolio item
const renderItem = ({ item }: { item: any }) => (
<Portofolio_BoxView key={item.id} data={item} />
);
// Generate pagination components using helper
const { ListEmptyComponent, ListFooterComponent } = createPaginationComponents({
loading: pagination.loading,
refreshing: pagination.refreshing,
listData: pagination.listData,
isInitialLoad: pagination.isInitialLoad,
emptyMessage: "Tidak ada portofolio",
skeletonCount: 3,
skeletonHeight: 100,
});
return (
<NewWrapper
listData={pagination.listData}
renderItem={renderItem}
refreshControl={
<RefreshControl
// IOS
tintColor={MainColor.yellow}
// Android
colors={[MainColor.yellow]}
progressBackgroundColor={MainColor.yellow}
refreshing={pagination.refreshing}
onRefresh={pagination.onRefresh}
/>
}
onEndReached={pagination.loadMore}
ListEmptyComponent={ListEmptyComponent}
ListFooterComponent={ListFooterComponent}
/>
);
}

View File

@@ -21,6 +21,7 @@ const AvatarAndBackground = ({
`/(application)/(image)/preview-image/${backgroundId}`
);
}}
disabled={!backgroundId}
>
<ImageBackground
source={

View File

@@ -70,7 +70,7 @@ export const drawerItemsProfile = ({
color={AccentColor.white}
/>
),
label: "Blocked List",
label: "Daftar Blokir",
path: `/(application)/profile/${id}/blocked-list`,
value: "blocked-list",
},

View File

@@ -12,7 +12,7 @@ export default function Profile_PortofolioSection({
}) {
return (
<>
<BaseBox>
<BaseBox >
<View>
<TextCustom bold size="large" align="center">
Portofolio

View File

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

View File

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

View File

@@ -0,0 +1,167 @@
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}
/>
</>
);
}

View File

@@ -1,3 +1,4 @@
import { typeRejectedData } from "@/types/type-collect-other";
import { apiConfig } from "../api-config";
export async function apiAdminDonation({
@@ -33,7 +34,7 @@ export async function apiAdminDonationUpdateStatus({
}: {
id: string;
changeStatus: "publish" | "review" | "reject";
data?: string;
data?: typeRejectedData;
}) {
try {
const response = await apiConfig.put(

View File

@@ -98,6 +98,7 @@ export async function apiAdminInvestmentUpdateInvoice({
data: {
investasiId: string;
lembarTerbeli: number;
senderId: string
};
}) {
try {

View File

@@ -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 {
const response = await apiConfig.get(`/mobile/forum/${id}/comment`);
const response = await apiConfig.get(`/mobile/forum/${id}/comment?page=${page}`);
return response.data;
} catch (error) {
throw error;

View File

@@ -12,9 +12,9 @@ export async function apiPortofolioCreate({ data }: { data: any }) {
}
}
export async function apiGetPortofolio({ id }: { id: string }) {
export async function apiGetPortofolio({ id, page = "1" }: { id: string; page?: string }) {
try {
const response = await apiConfig.get(`/mobile/portofolio?id=${id}`);
const response = await apiConfig.get(`/mobile/portofolio?id=${id}&page=${page}`);
return response.data;
} catch (error) {

View File

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

View File

@@ -41,16 +41,19 @@ export async function apiNotificationsSendById({
export async function apiGetNotificationsById({
id,
category,
page = "1",
}: {
id: string;
category: TypeNotificationCategoryApp;
page?: string;
}) {
console.log("ID", id);
console.log("Category", category);
console.log("Page", page);
try {
const response = await apiConfig.get(
`/mobile/notification/${id}?category=${category}`
`/mobile/notification/${id}?category=${category}&page=${page}`
);
return response.data;