Compare commits

..

13 Commits

Author SHA1 Message Date
48196cd46b Fix Load data pada halaman yang membutuhkan infinite load
Job – User App
- app/(application)/(user)/job/(tabs)/index.tsx
- app/(application)/(user)/job/(tabs)/status.tsx
- app/(application)/(user)/job/(tabs)/archive.tsx
- app/(application)/(user)/job/create.tsx

Job – Screens
- screens/Job/ScreenBeranda.tsx
- screens/Job/ScreenBeranda2.tsx
- screens/Job/MainViewStatus.tsx
- screens/Job/MainViewStatus2.tsx
- screens/Job/ScreenArchive.tsx
- screens/Job/ScreenArchive2.tsx

API – Job (Client)
- service/api-client/api-job.ts

Notification
- screens/Notification/ScreenNotification.tsx

Docs
- QWEN.md
- docs/prompt-for-qwen-code.md

### No Issue
2026-02-02 17:09:58 +08:00
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
14c0f0e499 User – Investment
app/(application)/(user)/investment/(tabs)/_layout.tsx

app/(application)/(user)/investment/(tabs)/portofolio.tsx

app/(application)/(user)/investment/create.tsx

Admin – Investment

app/(application)/admin/investment/[id]/reject-input.tsx

Screens / UI

screens/Invesment/BoxBerandaSection.tsx

API

service/api-admin/api-admin-investment.ts

Utils

utils/pickFile.ts

### No Issue
2026-01-20 17:41:02 +08:00
0262423c50 Fix notification to report comment
Fix:
User – Forum (Reporting & Preview)

app/(application)/(user)/forum/[id]/other-report-commentar.tsx

app/(application)/(user)/forum/[id]/other-report-posting.tsx

app/(application)/(user)/forum/[id]/preview-report-posting.tsx

app/(application)/(user)/forum/[id]/report-commentar.tsx

app/(application)/(user)/forum/[id]/report-posting.tsx

Admin – Forum Moderation

app/(application)/admin/forum/[id]/list-report-comment.tsx

app/(application)/admin/forum/report-posting.tsx

Layout

app/(application)/(user)/_layout.tsx

API Client & Admin

service/api-admin/api-admin-forum.ts

service/api-client/api-forum.ts

service/api-client/api-master.ts

Utils

utils/badWordsIndonesia.ts

### No Issue
2026-01-19 17:46:54 +08:00
c2682246d6 Fix notifikasi forum
FixL
 modified:   app/(application)/(user)/_layout.tsx
        modified:   app/(application)/(user)/forum/[id]/index.tsx
        modified:   app/(application)/(user)/forum/create.tsx
        modified:   app/(application)/admin/forum/[id]/list-report-posting.tsx
        modified:   screens/Home/tabsList.ts
        modified:   service/api-admin/api-admin-forum.ts
        modified:   service/api-client/api-forum.ts

Add:
 app/(application)/(user)/forum/[id]/preview-report-posting.tsx
        types/type-forum.ts

### No Issue
2026-01-17 15:59:00 +08:00
93 changed files with 4151 additions and 1405 deletions

0
QWEN.md Normal file
View File

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

@@ -616,6 +616,20 @@ export default function UserLayout() {
headerLeft: () => <BackButton />,
}}
/>
<Stack.Screen
name="forum/[id]/preview-report-posting"
options={{
title: "Laporan Postingan",
headerLeft: () => <BackButton />,
}}
/>
<Stack.Screen
name="forum/[id]/preview-report-comment"
options={{
title: "Laporan Komentar",
headerLeft: () => <BackButton />,
}}
/>
{/* ========== Maps Section ========= */}
<Stack.Screen

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,271 +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 { isBadContent } from "@/utils/badWordsIndonesia";
import { useFocusEffect, useLocalSearchParams } from "expo-router";
import _ from "lodash";
import { useCallback, useEffect, useState } from "react";
import { Alert } from "react-native";
interface CommentProps {
id: string;
isActive: boolean;
komentar: string;
createdAt: Date;
authorId: string;
Author: {
id: string;
username: string;
Profile: {
id: string;
imageId: string;
};
};
}
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<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

@@ -6,7 +6,7 @@ import {
} from "@/components";
import { MainColor } from "@/constants/color-palet";
import { useAuth } from "@/hooks/use-auth";
import { apiForumCreateReportCommentar } from "@/service/api-client/api-master";
import { apiForumCreateReportCommentar } from "@/service/api-client/api-forum";
import { router, useLocalSearchParams } from "expo-router";
import { useState } from "react";
import Toast from "react-native-toast-message";

View File

@@ -6,7 +6,7 @@ import {
} from "@/components";
import { MainColor } from "@/constants/color-palet";
import { useAuth } from "@/hooks/use-auth";
import { apiForumCreateReportPosting } from "@/service/api-client/api-master";
import { apiForumCreateReportPosting } from "@/service/api-client/api-forum";
import { router, useLocalSearchParams } from "expo-router";
import { useState } from "react";
import Toast from "react-native-toast-message";

View File

@@ -0,0 +1,91 @@
import {
BaseBox,
NewWrapper,
Spacing,
StackCustom,
TextCustom,
} from "@/components";
import ListSkeletonComponent from "@/components/_ShareComponent/ListSkeletonComponent";
import NoDataText from "@/components/_ShareComponent/NoDataText";
import CustomSkeleton from "@/components/_ShareComponent/SkeletonCustom";
import { apiForumGetReportComment } from "@/service/api-client/api-forum";
import { useFocusEffect, useLocalSearchParams } from "expo-router";
import _ from "lodash";
import { useCallback, useState } from "react";
export default function ForumPreviewReportComment() {
const { id } = useLocalSearchParams();
const [data, setData] = useState<any | null>(null);
const [listData, setListData] = useState<any | null>(null);
const [loading, setLoading] = useState<boolean>(false);
// Status
useFocusEffect(
useCallback(() => {
onLoadData(id as string);
}, [id])
);
const onLoadData = async (id: string) => {
try {
setLoading(true);
const response = await apiForumGetReportComment({ id });
setData(response.data);
setListData(response?.data?.Forum_ReportKomentar);
} catch (error) {
console.log("[ERROR]", error);
} finally {
setLoading(false);
}
};
return (
<>
<NewWrapper>
<StackCustom>
<TextCustom color="red" bold>
Komentar anda telah melanggar aturan forum ! Admin mengambil
tindakan untuk menghapus komentar anda!
</TextCustom>
{loading ? (
<CustomSkeleton height={100} />
) : (
<BaseBox>
<TextCustom>"{data?.komentar ? data?.komentar : "-"}"</TextCustom>
</BaseBox>
)}
</StackCustom>
<Spacing height={10} />
<TextCustom bold>Beberapa laporan yang telah diterima</TextCustom>
<Spacing height={10} />
{loading ? (
<ListSkeletonComponent />
) : _.isEmpty(listData) ? (
<NoDataText />
) : (
listData?.map((e: any, index: number) => (
<BaseBox key={index}>
{e?.deskripsi ? (
<StackCustom gap={"sm"}>
<TextCustom bold>Laporan Lainnya</TextCustom>
<TextCustom>{e?.deskripsi}</TextCustom>
</StackCustom>
) : (
<StackCustom gap={"sm"}>
<TextCustom bold>
{e?.ForumMaster_KategoriReport?.title}
</TextCustom>
<TextCustom>
{e?.ForumMaster_KategoriReport?.deskripsi}
</TextCustom>
</StackCustom>
)}
</BaseBox>
))
)}
</NewWrapper>
</>
);
}

View File

@@ -0,0 +1,91 @@
import {
BaseBox,
NewWrapper,
Spacing,
StackCustom,
TextCustom,
} from "@/components";
import ListSkeletonComponent from "@/components/_ShareComponent/ListSkeletonComponent";
import NoDataText from "@/components/_ShareComponent/NoDataText";
import CustomSkeleton from "@/components/_ShareComponent/SkeletonCustom";
import { apiForumGetReportPosting } from "@/service/api-client/api-forum";
import { useFocusEffect, useLocalSearchParams } from "expo-router";
import _ from "lodash";
import { useCallback, useState } from "react";
export default function ForumPreviewReportPosting() {
const { id } = useLocalSearchParams();
const [data, setData] = useState<any | null>(null);
const [listData, setListData] = useState<any | null>(null);
const [loading, setLoading] = useState<boolean>(false);
// Status
useFocusEffect(
useCallback(() => {
onLoadData(id as string);
}, [id])
);
const onLoadData = async (id: string) => {
try {
setLoading(true);
const response = await apiForumGetReportPosting({ id });
setData(response.data);
setListData(response?.data?.Forum_ReportPosting);
} catch (error) {
console.log("[ERROR]", error);
} finally {
setLoading(false);
}
};
return (
<>
<NewWrapper>
<StackCustom>
<TextCustom color="red" bold>
Postingan anda telah melanggar aturan forum ! Admin mengambil
tindakan untuk menghapus komentar anda!
</TextCustom>
{loading ? (
<CustomSkeleton height={100} />
) : (
<BaseBox>
<TextCustom>"{data?.diskusi ? data?.diskusi : "-"}"</TextCustom>
</BaseBox>
)}
</StackCustom>
<Spacing height={10} />
<TextCustom bold>Beberapa laporan yang telah diterima</TextCustom>
<Spacing height={10} />
{loading ? (
<ListSkeletonComponent />
) : _.isEmpty(listData) ? (
<NoDataText />
) : (
listData?.map((e: any) => (
<BaseBox key={e?.id}>
{e?.deskripsi ? (
<StackCustom gap={"sm"}>
<TextCustom bold>Laporan Lainnya</TextCustom>
<TextCustom>{e?.deskripsi}</TextCustom>
</StackCustom>
) : (
<StackCustom gap={"sm"}>
<TextCustom bold>
{e?.ForumMaster_KategoriReport?.title}
</TextCustom>
<TextCustom>
{e?.ForumMaster_KategoriReport?.deskripsi}
</TextCustom>
</StackCustom>
)}
</BaseBox>
))
)}
</NewWrapper>
</>
);
}

View File

@@ -8,7 +8,8 @@ import {
import { AccentColor, MainColor } from "@/constants/color-palet";
import { useAuth } from "@/hooks/use-auth";
import Forum_ReportListSection from "@/screens/Forum/ReportListSection";
import { apiForumCreateReportCommentar, apiMasterForumReportList } from "@/service/api-client/api-master";
import { apiForumCreateReportCommentar } from "@/service/api-client/api-forum";
import { apiMasterForumReportList } from "@/service/api-client/api-master";
import { router, useLocalSearchParams } from "expo-router";
import { useState, useEffect } from "react";
import Toast from "react-native-toast-message";

View File

@@ -9,8 +9,8 @@ import {
import { AccentColor, MainColor } from "@/constants/color-palet";
import { useAuth } from "@/hooks/use-auth";
import Forum_ReportListSection from "@/screens/Forum/ReportListSection";
import { apiForumCreateReportPosting } from "@/service/api-client/api-forum";
import {
apiForumCreateReportPosting,
apiMasterForumReportList,
} from "@/service/api-client/api-master";
import { router, useLocalSearchParams } from "expo-router";

View File

@@ -2,15 +2,14 @@ import {
BoxButtonOnFooter,
ButtonCustom,
TextAreaCustom,
ViewWrapper
ViewWrapper,
} from "@/components";
import AlertWarning from "@/components/Alert/AlertWarning";
import { useAuth } from "@/hooks/use-auth";
import { apiForumCreate } from "@/service/api-client/api-forum";
import { isBadContent } from "@/utils/badWordsIndonesia";
import { censorText, isBadContent } from "@/utils/badWordsIndonesia";
import { router } from "expo-router";
import { useState } from "react";
import { Alert } from "react-native";
import Toast from "react-native-toast-message";
export default function ForumCreate() {
@@ -19,16 +18,22 @@ export default function ForumCreate() {
const [isLoading, setIsLoading] = useState(false);
const handlerSubmit = async () => {
if (isBadContent(text)) {
AlertWarning({})
if (text.trim() === "") {
AlertWarning({
title: "Lengkapi Data",
description: "Postingan tidak boleh kosong",
});
return;
}
// Bisa di sensor atau return dan tidak bisa di post
const cencorContent = censorText(text)
const newData = {
diskusi: text,
diskusi: cencorContent,
authorId: user?.id,
};
try {
setIsLoading(true);
const response = await apiForumCreate({ data: newData });
@@ -50,6 +55,7 @@ export default function ForumCreate() {
const buttonFooter = (
<BoxButtonOnFooter>
<ButtonCustom
disabled={!text.trim() || isLoading}
isLoading={isLoading}
onPress={() => {
handlerSubmit();

View File

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

View File

@@ -1,9 +1,33 @@
import BackButtonFromNotification from "@/components/Button/BackButtonFromNotification";
import { ICON_SIZE_SMALL } from "@/constants/constans-value";
import { TabsStyles } from "@/styles/tabs-styles";
import { Feather, FontAwesome6, Ionicons } from "@expo/vector-icons";
import { Tabs } from "expo-router";
import { router, Tabs, useLocalSearchParams, useNavigation } from "expo-router";
import { useLayoutEffect } from "react";
export default function InvestmentTabsLayout() {
// const navigation = useNavigation();
// const { from, category } = useLocalSearchParams<{
// from?: string;
// category?: string;
// }>();
// console.log("from", from);
// console.log("category", category);
// // Atur header secara dinamis
// useLayoutEffect(() => {
// navigation.setOptions({
// headerLeft: () => (
// <BackButtonFromNotification
// from={from as string}
// category={category as string}
// />
// ),
// });
// }, [from, router, navigation]);
return (
<Tabs screenOptions={TabsStyles}>
<Tabs.Screen

View File

@@ -9,14 +9,16 @@ import { useAuth } from "@/hooks/use-auth";
import { dummyMasterStatus } from "@/lib/dummy-data/_master/status";
import Investment_StatusBox from "@/screens/Invesment/StatusBox";
import { apiInvestmentGetByStatus } from "@/service/api-client/api-investment";
import { useFocusEffect } from "expo-router";
import { useFocusEffect, useLocalSearchParams } from "expo-router";
import _ from "lodash";
import { useCallback, useState } from "react";
export default function InvestmentPortofolio() {
const { user } = useAuth();
const { status } = useLocalSearchParams<{ status?: string }>();
const [activeCategory, setActiveCategory] = useState<string | null>(
"publish"
status || "publish"
);
const [listData, setListData] = useState<any[]>([]);

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 () => {
@@ -167,7 +167,7 @@ export default function InvestmentCreate() {
text1: "Berhasil",
text2: response.message,
});
router.replace("/investment/portofolio");
router.replace("/investment/portofolio?status=review");
} else {
Toast.show({
type: "error",
@@ -224,7 +224,6 @@ export default function InvestmentCreate() {
onPress={() => {
pickFile({
setPdfUri: ({ uri, name, size }) => {
setPdf({ uri, name, size });
},
allowedType: "pdf",
@@ -265,6 +264,7 @@ export default function InvestmentCreate() {
<StackCustom gap={0}>
<TextInputCustom
disabled
required
placeholder="0"
label="Total Lembar"
@@ -357,7 +357,11 @@ export default function InvestmentCreate() {
)}
<Spacing />
<ButtonCustom isLoading={isLoading} onPress={() => handleSubmit()}>
<ButtonCustom
disabled={isLoading}
isLoading={isLoading}
onPress={() => handleSubmit()}
>
Simpan
</ButtonCustom>
</StackCustom>

View File

@@ -1,57 +1,10 @@
/* eslint-disable react-hooks/exhaustive-deps */
import { BaseBox, LoaderCustom, TextCustom, ViewWrapper } from "@/components";
import { useAuth } from "@/hooks/use-auth";
import { apiJobGetAll } from "@/service/api-client/api-job";
import { useFocusEffect } from "expo-router";
import _ from "lodash";
import { useCallback, useState } from "react";
import Job_ScreenArchive2 from "@/screens/Job/ScreenArchive2";
export default function JobArchive() {
const { user } = useAuth();
const [listData, setListData] = useState<any[]>([]);
const [isLoadData, setIsLoadData] = useState(false);
useFocusEffect(
useCallback(() => {
onLoadData();
}, [user?.id])
);
const onLoadData = async () => {
try {
setIsLoadData(true);
const response = await apiJobGetAll({
category: "archive",
authorId: user?.id,
});
setListData(response.data);
} catch (error) {
console.log("[ERROR]", error);
} finally {
setIsLoadData(false);
}
};
return (
<ViewWrapper hideFooter>
{isLoadData ? (
<LoaderCustom />
) : _.isEmpty(listData) ? (
<TextCustom align="center">Anda tidak memiliki arsip</TextCustom>
) : (
listData.map((item, index) => (
<BaseBox
key={index}
paddingTop={20}
paddingBottom={20}
href={`/job/${item.id}/archive`}
>
<TextCustom align="center" bold truncate size="large">
{item?.title || "-"}
</TextCustom>
</BaseBox>
))
)}
</ViewWrapper>
<>
<Job_ScreenArchive2 />
</>
);
}

View File

@@ -1,83 +1,10 @@
import {
AvatarUsernameAndOtherComponent,
BoxWithHeaderSection,
FloatingButton,
LoaderCustom,
SearchInput,
Spacing,
StackCustom,
TextCustom,
ViewWrapper,
} from "@/components";
import { apiJobGetAll } from "@/service/api-client/api-job";
import { router, useFocusEffect } from "expo-router";
import _ from "lodash";
import { useCallback, useState } from "react";
import Job_ScreenBeranda from "@/screens/Job/ScreenBeranda";
import Job_ScreenBeranda2 from "@/screens/Job/ScreenBeranda2";
export default function JobBeranda() {
const [listData, setListData] = useState<any[]>([]);
const [isLoadData, setIsLoadData] = useState(false);
const [search, setSearch] = useState("");
useFocusEffect(
useCallback(() => {
onLoadData(search);
}, [search])
);
const onLoadData = async (search: string) => {
try {
setIsLoadData(true);
const response = await apiJobGetAll({ search, category: "beranda" });
setListData(response.data);
} catch (error) {
console.log("[ERROR]", error);
} finally {
setIsLoadData(false);
}
};
const handleSearch = (search: string) => {
setSearch(search);
onLoadData(search);
};
return (
<ViewWrapper
hideFooter
floatingButton={
<FloatingButton onPress={() => router.push("/job/create")} />
}
headerComponent={
<SearchInput placeholder="Cari pekerjaan" onChangeText={handleSearch} />
}
>
{isLoadData ? (
<LoaderCustom />
) : _.isEmpty(listData) ? (
<TextCustom align="center">Belum ada lowongan</TextCustom>
) : (
listData.map((item, index) => (
<BoxWithHeaderSection
key={index}
onPress={() => router.push(`/job/${item.id}`)}
>
<StackCustom>
<AvatarUsernameAndOtherComponent
avatar={item?.Author?.Profile?.imageId}
avatarHref={`/profile/${item?.Author?.Profile?.id}`}
name={item?.Author?.username}
/>
<TextCustom truncate={2} align="center" bold size="large">
{item?.title || "-"}
</TextCustom>
</StackCustom>
<Spacing />
</BoxWithHeaderSection>
))
)}
<Spacing />
</ViewWrapper>
<>
<Job_ScreenBeranda2 />
</>
);
}

View File

@@ -1,91 +1,12 @@
/* eslint-disable react-hooks/exhaustive-deps */
import {
BaseBox,
LoaderCustom,
ScrollableCustom,
TextCustom,
ViewWrapper,
} from "@/components";
import { useAuth } from "@/hooks/use-auth";
import { dummyMasterStatus } from "@/lib/dummy-data/_master/status";
import { apiJobGetByStatus } from "@/service/api-client/api-job";
import { useFocusEffect, useLocalSearchParams } from "expo-router";
import _ from "lodash";
import { useCallback, useState } from "react";
import Job_MainViewStatus from "@/screens/Job/MainViewStatus";
import Job_MainViewStatus2 from "@/screens/Job/MainViewStatus2";
export default function JobStatus() {
const { user } = useAuth();
const { status } = useLocalSearchParams<{ status?: string }>();
console.log("STATUS", status);
const [activeCategory, setActiveCategory] = useState<string | null>(
status || "publish"
);
const [listData, setListData] = useState<any[]>([]);
const [isLoadList, setIsLoadList] = useState(false);
useFocusEffect(
useCallback(() => {
onLoadData();
}, [user?.id, activeCategory])
);
const onLoadData = async () => {
try {
setIsLoadList(true);
const response = await apiJobGetByStatus({
authorId: user?.id as string,
status: activeCategory as string,
});
setListData(response.data);
} catch (error) {
console.log("[ERROR]", error);
} finally {
setIsLoadList(false);
}
};
const handlePress = (item: any) => {
setActiveCategory(item.value);
// tambahkan logika lain seperti filter dsb.
};
const scrollComponent = (
<ScrollableCustom
data={dummyMasterStatus.map((e, i) => ({
id: i,
label: e.label,
value: e.value,
}))}
onButtonPress={handlePress}
activeId={activeCategory as any}
/>
);
return (
<>
<ViewWrapper headerComponent={scrollComponent} hideFooter>
{isLoadList ? (
<LoaderCustom />
) : _.isEmpty(listData) ? (
<TextCustom align="center">
Tidak ada data {activeCategory}
</TextCustom>
) : (
listData.map((e, i) => (
<BaseBox
key={i}
paddingTop={20}
paddingBottom={20}
href={`/job/${e?.id}/${activeCategory}/detail`}
>
<TextCustom align="center" bold truncate size="large">
{e?.title}
</TextCustom>
</BaseBox>
))
)}
</ViewWrapper>
{/* <Job_MainViewStatus /> */}
<Job_MainViewStatus2 />
</>
);
}

View File

@@ -1,13 +1,14 @@
import {
BoxButtonOnFooter,
ButtonCenteredOnly,
ButtonCustom,
InformationBox,
LandscapeFrameUploaded,
NewWrapper,
Spacing,
StackCustom,
TextAreaCustom,
TextInputCustom,
ViewWrapper
TextInputCustom
} from "@/components";
import DIRECTORY_ID from "@/constants/directory-id";
import { useAuth } from "@/hooks/use-auth";
@@ -99,16 +100,17 @@ export default function JobCreate() {
const buttonSubmit = () => {
return (
<>
<ButtonCustom isLoading={isLoading} onPress={() => handlerOnSubmit()}>
Simpan
</ButtonCustom>
<Spacing />
<BoxButtonOnFooter>
<ButtonCustom isLoading={isLoading} onPress={() => handlerOnSubmit()}>
Simpan
</ButtonCustom>
</BoxButtonOnFooter>
</>
);
};
return (
<ViewWrapper>
<NewWrapper footerComponent={buttonSubmit()}>
<StackCustom gap={"xs"}>
<InformationBox text="Poster atau gambar lowongan kerja bersifat opsional, tidak wajib untuk dimasukkan dan upload lah gambar yang sesuai dengan deskripsi lowongan kerja." />
@@ -160,9 +162,7 @@ export default function JobCreate() {
value={data.deskripsi}
onChangeText={(value) => setData({ ...data, deskripsi: value })}
/>
{buttonSubmit()}
</StackCustom>
</ViewWrapper>
</NewWrapper>
);
}

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

@@ -15,12 +15,11 @@ import { IconDot, IconView } from "@/components/_Icon/IconComponent";
import { IconTrash } from "@/components/_Icon/IconTrash";
import AdminBackButtonAntTitle from "@/components/_ShareComponent/Admin/BackButtonAntTitle";
import AdminComp_BoxTitle from "@/components/_ShareComponent/Admin/BoxTitlePage";
import AdminTitleTable from "@/components/_ShareComponent/Admin/TableTitle";
import AdminTableValue from "@/components/_ShareComponent/Admin/TableValue";
import { GridSpan_4_8 } from "@/components/_ShareComponent/GridSpan_4_8";
import { GridSpan_NewComponent } from "@/components/_ShareComponent/GridSpan_NewComponent";
import { MainColor } from "@/constants/color-palet";
import { ICON_SIZE_BUTTON } from "@/constants/constans-value";
import { useAuth } from "@/hooks/use-auth";
import {
apiAdminForumCommentById,
apiAdminForumDeactivateComment,
@@ -35,6 +34,7 @@ import Toast from "react-native-toast-message";
export default function AdminForumReportComment() {
const { id } = useLocalSearchParams();
const { user } = useAuth();
const [data, setData] = useState<any | null>(null);
const [listReport, setListReport] = useState<any[] | null>(null);
const [loadList, setLoadList] = useState(false);
@@ -111,9 +111,13 @@ export default function AdminForumReportComment() {
<AdminComp_BoxTitle title="Daftar Report Komentar" />
<StackCustom gap={"sm"}>
<StackCustom gap={"sm"}>
<GridSpan_NewComponent
text1={<TextCustom bold align="center">Aksi</TextCustom>}
text1={
<TextCustom bold align="center">
Aksi
</TextCustom>
}
text2={<TextCustom bold>Pelapor</TextCustom>}
text3={<TextCustom bold>Kategori Report</TextCustom>}
/>
@@ -129,22 +133,24 @@ export default function AdminForumReportComment() {
<View key={index}>
<GridSpan_NewComponent
text1={
<CenterCustom>
<ActionIcon
icon={<IconView size={ICON_SIZE_BUTTON} color="black" />}
onPress={() => {
setOpenDrawerAction(true);
setSelectedReport({
id: item.id,
username: item.User?.username,
kategori: item.ForumMaster_KategoriReport?.title,
keterangan:
item.ForumMaster_KategoriReport?.deskripsi,
deskripsi: item.deskripsi,
});
}}
/>
</CenterCustom>
<CenterCustom>
<ActionIcon
icon={
<IconView size={ICON_SIZE_BUTTON} color="black" />
}
onPress={() => {
setOpenDrawerAction(true);
setSelectedReport({
id: item.id,
username: item.User?.username,
kategori: item.ForumMaster_KategoriReport?.title,
keterangan:
item.ForumMaster_KategoriReport?.deskripsi,
deskripsi: item.deskripsi,
});
}}
/>
</CenterCustom>
}
text2={
<TextCustom truncate={1}>
@@ -188,15 +194,18 @@ export default function AdminForumReportComment() {
onPressRight: async () => {
const deleteComment = await apiAdminForumDeactivateComment({
id: id as string,
data: {
senderId: user?.id as string,
},
});
if (!deleteComment.success) {
Toast.show({
type: "error",
text1: "Komentar gagal dihapus",
});
return;
}
// if (!deleteComment.success) {
// Toast.show({
// type: "error",
// text1: "Komentar gagal dihapus",
// });
// return;
// }
setOpenDrawer(false);
Toast.show({

View File

@@ -16,12 +16,11 @@ import { IconDot, IconView } from "@/components/_Icon/IconComponent";
import { IconTrash } from "@/components/_Icon/IconTrash";
import AdminBackButtonAntTitle from "@/components/_ShareComponent/Admin/BackButtonAntTitle";
import AdminComp_BoxTitle from "@/components/_ShareComponent/Admin/BoxTitlePage";
import AdminTitleTable from "@/components/_ShareComponent/Admin/TableTitle";
import AdminTableValue from "@/components/_ShareComponent/Admin/TableValue";
import { GridSpan_4_8 } from "@/components/_ShareComponent/GridSpan_4_8";
import { GridSpan_NewComponent } from "@/components/_ShareComponent/GridSpan_NewComponent";
import { MainColor } from "@/constants/color-palet";
import { ICON_SIZE_BUTTON } from "@/constants/constans-value";
import { useAuth } from "@/hooks/use-auth";
import {
apiAdminForumDeactivatePosting,
apiAdminForumListReportPostingById,
@@ -35,6 +34,7 @@ import { Divider } from "react-native-paper";
import Toast from "react-native-toast-message";
export default function AdminForumReportPosting() {
const { user } = useAuth();
const { id } = useLocalSearchParams();
const [openDrawerPage, setOpenDrawerPage] = useState(false);
const [openDrawerAction, setOpenDrawerAction] = useState(false);
@@ -215,6 +215,9 @@ export default function AdminForumReportPosting() {
onPressRight: async () => {
const response = await apiAdminForumDeactivatePosting({
id: id as string,
data: {
senderId: user?.id as string,
},
});
if (!response.success) {

View File

@@ -73,7 +73,7 @@ export default function AdminForumReportPosting() {
<GridSpan_NewComponent
text1={
<TextCustom bold truncate>
Username
Pelapor
</TextCustom>
}
text2={

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

@@ -7,34 +7,39 @@ import {
} from "@/components";
import AdminBackButtonAntTitle from "@/components/_ShareComponent/Admin/BackButtonAntTitle";
import AdminButtonReject from "@/components/_ShareComponent/Admin/ButtonReject";
import { apiAdminInvestasiUpdateByStatus, apiAdminInvestmentDetailById } from "@/service/api-admin/api-admin-investment";
import { useAuth } from "@/hooks/use-auth";
import {
apiAdminInvestasiUpdateByStatus,
apiAdminInvestmentDetailById,
} from "@/service/api-admin/api-admin-investment";
import { router, useFocusEffect, useLocalSearchParams } from "expo-router";
import { useCallback, useState } from "react";
import Toast from "react-native-toast-message";
export default function AdminInvestmentRejectInput() {
const { user } = useAuth();
const { id, status } = useLocalSearchParams();
console.log("[STATUS]", status);
const [value, setValue] = useState<any | null>(null);
const [isLoading , setLoading] = useState(false)
const [isLoading, setLoading] = useState(false);
useFocusEffect(
useCallback(() => {
onLoadData();
}, [id])
);
const onLoadData = async () => {
try {
const response = await apiAdminInvestmentDetailById({ id: id as string });
console.log("[DATA]", JSON.stringify(response, null, 2));
if (response.success) {
setValue(response.data?.catatan);
}
} catch (error) {
console.log(error);
useFocusEffect(
useCallback(() => {
onLoadData();
}, [id])
);
const onLoadData = async () => {
try {
const response = await apiAdminInvestmentDetailById({ id: id as string });
console.log("[DATA]", JSON.stringify(response, null, 2));
if (response.success) {
setValue(response.data?.catatan);
}
};
} catch (error) {
console.log(error);
}
};
const handlerSubmit = async () => {
if (!value) {
@@ -45,12 +50,23 @@ export default function AdminInvestmentRejectInput() {
return;
}
if (!user?.id) {
Toast.show({
type: "error",
text1: "User tidak ditemukan",
});
return;
}
try {
setLoading(true)
setLoading(true);
const response = await apiAdminInvestasiUpdateByStatus({
id: id as string,
status: "reject",
data: value,
data: {
catatan: value,
senderId: user?.id as string,
},
});
console.log("[RESPONSE]", JSON.stringify(response, null, 2));
@@ -76,7 +92,7 @@ export default function AdminInvestmentRejectInput() {
} catch (error) {
console.error(["ERROR"], error);
} finally {
setLoading(false)
setLoading(false);
}
};

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/Job/ScreenArchive2.tsx
Fun fecth: apiJobGetByStatus
File fetch: service/api-client/api-job.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

@@ -13,7 +13,8 @@ export const tabsHome: any = ({
icon: "chatbubble-ellipses-outline",
activeIcon: "chatbubble-ellipses",
label: "Forum",
path: acceptedForumTermsAt ? "/forum" : "/forum/terms",
// path: acceptedForumTermsAt ? "/forum" : "/forum/terms",
path: "/forum",
isActive: true,
disabled: false,
},

View File

@@ -7,7 +7,6 @@ import {
TextCustom,
} from "@/components";
import API_STRORAGE from "@/constants/base-url-api-strorage";
import { MainColor } from "@/constants/color-palet";
import DUMMY_IMAGE from "@/constants/dummy-image-value";
import { countDownAndCondition } from "@/utils/countDownAndCondition";
import { Ionicons } from "@expo/vector-icons";
@@ -22,8 +21,6 @@ export default function Investment_BoxBerandaSection({
id: string;
data: any;
}) {
// console.log("[DATA By one]", JSON.stringify(data, null, 2));
const [value, setValue] = useState({
sisa: 0,
reminder: false,
@@ -33,8 +30,6 @@ export default function Investment_BoxBerandaSection({
updateCountDown();
}, [data]);
console.log("[DATA BERANDA]", JSON.stringify(data, null, 2));
const updateCountDown = () => {
const countDown = countDownAndCondition({
duration: data?.pencarianInvestor,

View File

@@ -0,0 +1,91 @@
/* eslint-disable react-hooks/exhaustive-deps */
import {
BaseBox,
LoaderCustom,
ScrollableCustom,
TextCustom,
ViewWrapper,
} from "@/components";
import { useAuth } from "@/hooks/use-auth";
import { dummyMasterStatus } from "@/lib/dummy-data/_master/status";
import { apiJobGetByStatus } from "@/service/api-client/api-job";
import { useFocusEffect, useLocalSearchParams } from "expo-router";
import _ from "lodash";
import { useCallback, useState } from "react";
export default function Job_MainViewStatus() {
const { user } = useAuth();
const { status } = useLocalSearchParams<{ status?: string }>();
console.log("STATUS", status);
const [activeCategory, setActiveCategory] = useState<string | null>(
status || "publish"
);
const [listData, setListData] = useState<any[]>([]);
const [isLoadList, setIsLoadList] = useState(false);
useFocusEffect(
useCallback(() => {
onLoadData();
}, [user?.id, activeCategory])
);
const onLoadData = async () => {
try {
setIsLoadList(true);
const response = await apiJobGetByStatus({
authorId: user?.id as string,
status: activeCategory as string,
});
setListData(response.data);
} catch (error) {
console.log("[ERROR]", error);
} finally {
setIsLoadList(false);
}
};
const handlePress = (item: any) => {
setActiveCategory(item.value);
// tambahkan logika lain seperti filter dsb.
};
const scrollComponent = (
<ScrollableCustom
data={dummyMasterStatus.map((e, i) => ({
id: i,
label: e.label,
value: e.value,
}))}
onButtonPress={handlePress}
activeId={activeCategory as any}
/>
);
return (
<>
<ViewWrapper headerComponent={scrollComponent} hideFooter>
{isLoadList ? (
<LoaderCustom />
) : _.isEmpty(listData) ? (
<TextCustom align="center">
Tidak ada data {activeCategory}
</TextCustom>
) : (
listData.map((e, i) => (
<BaseBox
key={i}
paddingTop={20}
paddingBottom={20}
href={`/job/${e?.id}/${activeCategory}/detail`}
>
<TextCustom align="center" bold truncate size="large">
{e?.title}
</TextCustom>
</BaseBox>
))
)}
</ViewWrapper>
</>
);
}

View File

@@ -0,0 +1,111 @@
/* eslint-disable react-hooks/exhaustive-deps */
import {
BaseBox,
ScrollableCustom,
TextCustom,
ViewWrapper,
} from "@/components";
import { MainColor } from "@/constants/color-palet";
import { createPaginationComponents } from "@/helpers/paginationHelpers";
import { useAuth } from "@/hooks/use-auth";
import { usePagination } from "@/hooks/use-pagination";
import { dummyMasterStatus } from "@/lib/dummy-data/_master/status";
import { apiJobGetByStatus } from "@/service/api-client/api-job";
import { useFocusEffect, useLocalSearchParams } from "expo-router";
import _ from "lodash";
import { useState } from "react";
import { RefreshControl, View } from "react-native";
import NewWrapper from "@/components/_ShareComponent/NewWrapper";
import { PAGINATION_DEFAULT_TAKE } from "@/constants/constans-value";
export default function Job_MainViewStatus2() {
const { user } = useAuth();
const { status } = useLocalSearchParams<{ status?: string }>();
console.log("STATUS", status);
const [activeCategory, setActiveCategory] = useState<string | null>(
status || "publish"
);
// Setup pagination
const pagination = usePagination({
fetchFunction: async (page) => {
if (!user?.id) return { data: [] };
return await apiJobGetByStatus({
authorId: user?.id as string,
status: activeCategory as string,
page: String(page),
});
},
pageSize: PAGINATION_DEFAULT_TAKE,
dependencies: [user?.id, activeCategory],
onError: (error) => console.error("[ERROR] Fetch job by status:", error),
});
// Generate komponen
const { ListEmptyComponent, ListFooterComponent } = createPaginationComponents({
loading: pagination.loading,
refreshing: pagination.refreshing,
listData: pagination.listData,
emptyMessage: `Tidak ada data ${activeCategory}`,
skeletonCount: PAGINATION_DEFAULT_TAKE,
skeletonHeight: 100,
});
// Render item job
const renderJobItem = ({ item }: { item: any }) => (
<BaseBox
key={item.id}
paddingTop={20}
paddingBottom={20}
href={`/job/${item?.id}/${activeCategory}/detail`}
>
<TextCustom align="center" bold truncate size="large">
{item?.title}
</TextCustom>
</BaseBox>
);
const handlePress = (item: any) => {
setActiveCategory(item.value);
// Reset pagination saat kategori berubah
pagination.reset();
};
const scrollComponent = (
<ScrollableCustom
data={dummyMasterStatus.map((e, i) => ({
id: i,
label: e.label,
value: e.value,
}))}
onButtonPress={handlePress}
activeId={activeCategory as any}
/>
);
return (
<NewWrapper
headerComponent={
<View style={{ paddingTop: 8 }}>
{scrollComponent}
</View>
}
listData={pagination.listData}
renderItem={renderJobItem}
refreshControl={
<RefreshControl
tintColor={MainColor.yellow}
colors={[MainColor.yellow]}
refreshing={pagination.refreshing}
onRefresh={pagination.onRefresh}
/>
}
onEndReached={pagination.loadMore}
ListEmptyComponent={ListEmptyComponent}
ListFooterComponent={ListFooterComponent}
hideFooter
/>
);
}

View File

@@ -0,0 +1,57 @@
/* eslint-disable react-hooks/exhaustive-deps */
import { BaseBox, LoaderCustom, TextCustom, ViewWrapper } from "@/components";
import { useAuth } from "@/hooks/use-auth";
import { apiJobGetAll } from "@/service/api-client/api-job";
import { useFocusEffect } from "expo-router";
import _ from "lodash";
import { useCallback, useState } from "react";
export default function Job_ScreenArchive() {
const { user } = useAuth();
const [listData, setListData] = useState<any[]>([]);
const [isLoadData, setIsLoadData] = useState(false);
useFocusEffect(
useCallback(() => {
onLoadData();
}, [user?.id])
);
const onLoadData = async () => {
try {
setIsLoadData(true);
const response = await apiJobGetAll({
category: "archive",
authorId: user?.id,
});
setListData(response.data);
} catch (error) {
console.log("[ERROR]", error);
} finally {
setIsLoadData(false);
}
};
return (
<ViewWrapper hideFooter>
{isLoadData ? (
<LoaderCustom />
) : _.isEmpty(listData) ? (
<TextCustom align="center">Anda tidak memiliki arsip</TextCustom>
) : (
listData.map((item, index) => (
<BaseBox
key={index}
paddingTop={20}
paddingBottom={20}
href={`/job/${item.id}/archive`}
>
<TextCustom align="center" bold truncate size="large">
{item?.title || "-"}
</TextCustom>
</BaseBox>
))
)}
</ViewWrapper>
);
}

View File

@@ -0,0 +1,76 @@
/* eslint-disable react-hooks/exhaustive-deps */
import { BaseBox, TextCustom, ViewWrapper } from "@/components";
import { MainColor } from "@/constants/color-palet";
import { createPaginationComponents } from "@/helpers/paginationHelpers";
import { useAuth } from "@/hooks/use-auth";
import { usePagination } from "@/hooks/use-pagination";
import { apiJobGetAll } from "@/service/api-client/api-job";
import { useFocusEffect } from "expo-router";
import _ from "lodash";
import { useState } from "react";
import { RefreshControl } from "react-native";
import NewWrapper from "@/components/_ShareComponent/NewWrapper";
import { PAGINATION_DEFAULT_TAKE } from "@/constants/constans-value";
export default function Job_ScreenArchive2() {
const { user } = useAuth();
// Setup pagination
const pagination = usePagination({
fetchFunction: async (page) => {
if (!user?.id) return { data: [] };
return await apiJobGetAll({
category: "archive",
authorId: user?.id,
page: String(page),
});
},
pageSize: PAGINATION_DEFAULT_TAKE,
dependencies: [user?.id],
onError: (error) => console.error("[ERROR] Fetch job archive:", error),
});
// Generate komponen
const { ListEmptyComponent, ListFooterComponent } = createPaginationComponents({
loading: pagination.loading,
refreshing: pagination.refreshing,
listData: pagination.listData,
emptyMessage: "Anda tidak memiliki arsip",
skeletonCount: PAGINATION_DEFAULT_TAKE,
skeletonHeight: 80,
});
// Render item job
const renderJobItem = ({ item }: { item: any }) => (
<BaseBox
key={item.id}
paddingTop={20}
paddingBottom={20}
href={`/job/${item.id}/archive`}
>
<TextCustom align="center" bold truncate size="large">
{item?.title || "-"}
</TextCustom>
</BaseBox>
);
return (
<NewWrapper
listData={pagination.listData}
renderItem={renderJobItem}
refreshControl={
<RefreshControl
tintColor={MainColor.yellow}
colors={[MainColor.yellow]}
refreshing={pagination.refreshing}
onRefresh={pagination.onRefresh}
/>
}
onEndReached={pagination.loadMore}
ListEmptyComponent={ListEmptyComponent}
ListFooterComponent={ListFooterComponent}
hideFooter
/>
);
}

View File

@@ -0,0 +1,83 @@
import {
AvatarUsernameAndOtherComponent,
BoxWithHeaderSection,
FloatingButton,
LoaderCustom,
SearchInput,
Spacing,
StackCustom,
TextCustom,
ViewWrapper,
} from "@/components";
import { apiJobGetAll } from "@/service/api-client/api-job";
import { router, useFocusEffect } from "expo-router";
import _ from "lodash";
import { useCallback, useState } from "react";
export default function Job_ScreenBeranda() {
const [listData, setListData] = useState<any[]>([]);
const [isLoadData, setIsLoadData] = useState(false);
const [search, setSearch] = useState("");
useFocusEffect(
useCallback(() => {
onLoadData(search);
}, [search])
);
const onLoadData = async (search: string) => {
try {
setIsLoadData(true);
const response = await apiJobGetAll({ search, category: "beranda" });
setListData(response.data);
} catch (error) {
console.log("[ERROR]", error);
} finally {
setIsLoadData(false);
}
};
const handleSearch = (search: string) => {
setSearch(search);
onLoadData(search);
};
return (
<ViewWrapper
hideFooter
floatingButton={
<FloatingButton onPress={() => router.push("/job/create")} />
}
headerComponent={
<SearchInput placeholder="Cari pekerjaan" onChangeText={handleSearch} />
}
>
{isLoadData ? (
<LoaderCustom />
) : _.isEmpty(listData) ? (
<TextCustom align="center">Belum ada lowongan</TextCustom>
) : (
listData.map((item, index) => (
<BoxWithHeaderSection
key={index}
onPress={() => router.push(`/job/${item.id}`)}
>
<StackCustom>
<AvatarUsernameAndOtherComponent
avatar={item?.Author?.Profile?.imageId}
avatarHref={`/profile/${item?.Author?.Profile?.id}`}
name={item?.Author?.username}
/>
<TextCustom truncate={2} align="center" bold size="large">
{item?.title || "-"}
</TextCustom>
</StackCustom>
<Spacing />
</BoxWithHeaderSection>
))
)}
<Spacing />
</ViewWrapper>
);
}

View File

@@ -0,0 +1,105 @@
import {
AvatarUsernameAndOtherComponent,
BoxWithHeaderSection,
FloatingButton,
SearchInput,
Spacing,
StackCustom,
TextCustom,
ViewWrapper,
} from "@/components";
import { MainColor } from "@/constants/color-palet";
import { createPaginationComponents } from "@/helpers/paginationHelpers";
import { usePagination } from "@/hooks/use-pagination";
import { apiJobGetAll } from "@/service/api-client/api-job";
import { router, useFocusEffect } from "expo-router";
import _ from "lodash";
import { useState } from "react";
import { RefreshControl, View } from "react-native";
import NewWrapper from "@/components/_ShareComponent/NewWrapper";
import { PAGINATION_DEFAULT_TAKE } from "@/constants/constans-value";
const PAGE_SIZE = 10;
export default function Job_ScreenBeranda2() {
const [search, setSearch] = useState("");
// Setup pagination
const pagination = usePagination({
fetchFunction: async (page, searchQuery) => {
return await apiJobGetAll({
search: searchQuery || "",
category: "beranda",
page: String(page),
});
},
pageSize: PAGINATION_DEFAULT_TAKE,
searchQuery: search,
dependencies: [],
onError: (error) => console.error("[ERROR] Fetch job:", error),
});
// Generate komponen
const { ListEmptyComponent, ListFooterComponent } =
createPaginationComponents({
loading: pagination.loading,
refreshing: pagination.refreshing,
listData: pagination.listData,
searchQuery: search,
emptyMessage: "Belum ada lowongan",
emptySearchMessage: "Tidak ada hasil pencarian",
skeletonCount: 5,
skeletonHeight: 150,
});
// Render item job
const renderJobItem = ({ item }: { item: any }) => (
<BoxWithHeaderSection
key={item.id}
onPress={() => router.push(`/job/${item.id}`)}
>
<StackCustom>
<AvatarUsernameAndOtherComponent
avatar={item?.Author?.Profile?.imageId}
avatarHref={`/profile/${item?.Author?.Profile?.id}`}
name={item?.Author?.username}
/>
<TextCustom truncate={2} align="center" bold size="large">
{item?.title || "-"}
</TextCustom>
</StackCustom>
<Spacing />
</BoxWithHeaderSection>
);
return (
<NewWrapper
hideFooter
headerComponent={
<View style={{ paddingTop: 8 }}>
<SearchInput
placeholder="Cari pekerjaan"
onChangeText={_.debounce((text) => setSearch(text), 500)}
/>
</View>
}
floatingButton={
<FloatingButton onPress={() => router.push("/job/create")} />
}
listData={pagination.listData}
renderItem={renderJobItem}
refreshControl={
<RefreshControl
tintColor={MainColor.yellow}
colors={[MainColor.yellow]}
refreshing={pagination.refreshing}
onRefresh={pagination.onRefresh}
/>
}
onEndReached={pagination.loadMore}
ListEmptyComponent={ListEmptyComponent}
ListFooterComponent={ListFooterComponent}
/>
);
}

View File

@@ -0,0 +1,250 @@
/* 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) => {
console.log("ITEM", item.value);
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

@@ -57,9 +57,19 @@ export async function apiAdminForumListReportCommentById({
}
}
export async function apiAdminForumDeactivateComment({ id }: { id: string }) {
export async function apiAdminForumDeactivateComment({
id,
data,
}: {
id: string;
data: { senderId: string };
}) {
console.log("data", data)
try {
const response = await apiConfig.put(`/mobile/admin/forum/${id}/comment`);
const response = await apiConfig.put(`/mobile/admin/forum/${id}/comment`, {
data: data,
});
return response.data;
} catch (error) {
throw error;
@@ -81,9 +91,17 @@ export async function apiAdminForumListReportPostingById({
}
}
export async function apiAdminForumDeactivatePosting({ id }: { id: string }) {
export async function apiAdminForumDeactivatePosting({
id,
data,
}: {
id: string;
data: { senderId: string };
}) {
try {
const response = await apiConfig.put(`/mobile/admin/forum/${id}`);
const response = await apiConfig.put(`/mobile/admin/forum/${id}`, {
data: data,
});
return response.data;
} catch (error) {
throw error;

View File

@@ -1,3 +1,4 @@
import { typeRejectedData } from "@/types/type-collect-other";
import { apiConfig } from "../api-config";
export async function apiAdminInvestment({
@@ -38,7 +39,7 @@ export async function apiAdminInvestasiUpdateByStatus({
}: {
id: string;
status: "publish" | "review" | "reject";
data: any;
data: typeRejectedData;
}) {
try {
const response = await apiConfig.put(
@@ -97,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;
@@ -119,3 +119,62 @@ export async function apiForumDeleteComment({ id }: { id: string }) {
throw error;
}
}
export async function apiForumGetReportPosting({id}: {id:string}) {
try {
const response = await apiConfig.get(`/mobile/forum/${id}/preview-report-posting`);
return response.data;
} catch (error) {
throw error;
}
}
export async function apiForumGetReportComment({id}: {id:string}) {
try {
const response = await apiConfig.get(`/mobile/forum/${id}/preview-report-comment`);
return response.data;
} catch (error) {
throw error;
}
}
export async function apiForumCreateReportPosting({
id,
data,
}: {
id: string;
data: any;
}) {
try {
const response = await apiConfig.post(
`/mobile/forum/${id}/report-posting`,
{
data: data,
}
);
return response.data;
} catch (error) {
throw error;
}
}
export async function apiForumCreateReportCommentar({
id,
data,
}: {
id: string;
data: any;
}) {
try {
const response = await apiConfig.post(
`/mobile/forum/${id}/report-commentar`,
{
data: data,
}
);
return response.data;
} catch (error) {
throw error;
}
}

View File

@@ -14,12 +14,14 @@ export async function apiJobCreate(data: any) {
export async function apiJobGetByStatus({
authorId,
status,
page = "1",
}: {
authorId: string;
status: string;
page?: string;
}) {
try {
const response = await apiConfig.get(`/mobile/job/${authorId}/${status}`);
const response = await apiConfig.get(`/mobile/job/${authorId}/${status}?page=${page}`);
return response.data;
} catch (error) {
throw error;
@@ -63,10 +65,12 @@ export async function apiJobGetAll({
search,
category,
authorId,
page = "1",
}: {
search?: string;
category: "archive" | "beranda";
authorId?: string;
page?: string;
}) {
try {
let categoryText = category ? `?category=${category}` : "";
@@ -74,8 +78,9 @@ export async function apiJobGetAll({
categoryText = `?category=${category}&authorId=${authorId}`;
}
const searchText = search ? `&search=${search}` : "";
const pageText = `&page=${page}`;
const response = await apiConfig.get(
`/mobile/job${categoryText}${searchText}`
`/mobile/job${categoryText}${searchText}${pageText}`
);
return response.data;
} catch (error) {

View File

@@ -77,46 +77,6 @@ export async function apiMasterForumReportList() {
// ================== END MASTER FORUM ================== //
export async function apiForumCreateReportPosting({
id,
data,
}: {
id: string;
data: any;
}) {
try {
const response = await apiConfig.post(
`/mobile/forum/${id}/report-posting`,
{
data: data,
}
);
return response.data;
} catch (error) {
throw error;
}
}
export async function apiForumCreateReportCommentar({
id,
data,
}: {
id: string;
data: any;
}) {
try {
const response = await apiConfig.post(
`/mobile/forum/${id}/report-commentar`,
{
data: data,
}
);
return response.data;
} catch (error) {
throw error;
}
}
// ================== START MASTER INVESTMENT ================== //
export async function apiMasterInvestment({

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;

15
types/type-forum.ts Normal file
View File

@@ -0,0 +1,15 @@
export interface TypeForum_CommentProps {
id: string;
isActive: boolean;
komentar: string;
createdAt: Date;
authorId: string;
Author: {
id: string;
username: string;
Profile: {
id: string;
imageId: string;
};
};
}

View File

@@ -5,11 +5,11 @@ const badWordsIndonesia = [
'anjing', 'babi', 'bangsat', 'bodoh', 'goblok', 'idiot', 'jancok', 'jembut', 'kampret',
'kontol', 'memek', 'ngentot', 'peler', 'puki', 'sialan', 'tai', 'tolol', 'wibu',
'anjingg', 'babbii', 'bangsaat', 'gobllokk', 'jancokk', 'kontoll', 'memekk', 'ngentott',
'pelerr', 'puuki', 'sialann', 'taii', 'tololl', 'wibuu',
'pelerr', 'puuki', 'sialann', 'taii', 'tololl', 'wibuu', 'cicing',
// 🔥 Kata Sindiran & Penghinaan
'bego', 'dungu', 'edan', 'gila', 'goblog', 'kampang', 'kampret', 'keparat', 'lonte',
'main mata', 'monyet', 'najis', 'ngeyel', 'ngibul', 'ngomong seenaknya', 'ngurangin',
'monyet', 'najis', 'ngeyel', 'ngibul', 'ngomong seenaknya', 'ngurangin',
'ngutang', 'ngurusin urusan orang', 'pemalas', 'pengecut', 'penipu', 'sinting',
'begoo', 'dunguu', 'goblogg', 'kampangg', 'keparatt', 'lontee', 'monyyet', 'najiss',
'ngeyell', 'ngibull', 'ngomongg seenaknya', 'nguranginn', 'ngutangg', 'pemalass',
@@ -23,18 +23,17 @@ const badWordsIndonesia = [
// 💸 Kata Spam / Promosi Ilegal
'judi', 'togel', 'slot', 'casino', 'poker', 'qq', 'bandar', 'agen', 'link', 'wa',
'whatsapp', 'telepon', 'nomor', 'hp', 'sms', 'grup', 'join', 'daftar', 'bonus',
'deposit', 'withdraw', 'uang', 'duit', 'rp', 'ratusan', 'juta', 'milyar',
'deposit', 'withdraw',
'judii', 'togell', 'slotss', 'casinoo', 'pokerr', 'qqq', 'bandarr', 'agenn', 'linkk',
'waa', 'whatsappp', 'teleponn', 'nomorr', 'hpp', 'smss', 'grupp', 'jooin', 'daftarr',
'bonuss', 'depositt', 'withdraww', 'uangs', 'duitt', 'rpp', 'ratusann', 'jutaa', 'milyarr',
'waa',
'depositt', 'withdraww', 'rpp',
// 🧩 Variasi Penulisan (Bypass Filter)
'a*njing', 'b*b*i', 'b*ngsat', 'g*blok', 'k*nt*l', 'm*m*k', 'n*g*nt*t', 'p*l*r',
't*i', 't*l*l', 'j*n*c*k', 'j*m*b*t', 'k*m*p*r*t', 's*i*l*a*n', 'w*b*u',
't*i', 't*l*l', 'j*n*c*k', 'j*m*b*t', 'k*m*p*r*t', 's*i*l*a*n', 'w*b*u',
'a.n.j.i.n.g', 'b.a.b.i', 'b.a.n.g.s.a.t', 'g.o.b.l.o.k', 'k.o.n.t.o.l', 'm.e.m.e.k',
'n.g.e.n.t.o.t', 'p.e.l.e.r', 't.a.i', 't.o.l.o.l', 'j.a.n.c.o.k', 'j.e.m.b.u.t',
'k.a.m.p.r.e.t', 's.i.a.l.a.n', 'w.i.b.u',
'k.a.m.p.r.e.t', 's.i.a.l.a.n', 'w.i.b.u', 'c.i.c.i.n.g',
// 📱 Variasi dengan Angka & Simbol
'4nj1ng', 'b4b1', 'b4ngs4t', 'g0bl0k', 'k0nt0l', 'm3m3k', 'ng3nt0t', 'p3l3r',
@@ -43,8 +42,6 @@ const badWordsIndonesia = [
'p3l3rr', 't4ii', 't0l0ll', 'j4nc0kk', 'j3mbutt', 'k4mpr3tt', 's14l4nn', 'w1buu',
// 🗣️ Kata yang Sering Digunakan dalam Konteks Negatif
'dasar', 'kamu', 'kau', 'lu', 'lo', 'gue', 'gua', 'kita', 'kami', 'mereka',
'dasarr', 'kamuu', 'kauu', 'luu', 'loo', 'guee', 'guua', 'kitaa', 'kamii', 'merekaa',
'dasar bodoh', 'dasar goblok', 'dasar bangsat', 'dasar idiot', 'dasar sialan',
'dasar bego', 'dasar dungu', 'dasar edan', 'dasar gila', 'dasar sinting',
'dasar pemalas', 'dasar pengecut', 'dasar penipu', 'dasar najis', 'dasar kampret',

View File

@@ -1,6 +1,6 @@
import * as ImagePicker from "expo-image-picker";
import * as DocumentPicker from "expo-document-picker";
import { Alert } from "react-native";
import { Alert, Platform } from "react-native";
const ALLOWED_IMAGE_EXTENSIONS = ["jpg", "jpeg", "png"];
const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB
@@ -33,22 +33,52 @@ export default async function pickFile({
await pickImage(setImageUri, aspectRatio);
} else {
// Jika tidak, tawarkan pilihan rasio (default [4,3])
showAspectRatioChoice(setImageUri);
// 🚀 Hanya tampilkan pilihan rasio di ANDROID
if (Platform.OS === "android") {
showAspectRatioChoice(setImageUri);
} else {
// iOS: langsung buka galeri dengan default [4, 3]
await pickImage(setImageUri, [4, 3]);
}
}
} else if (allowedType === "pdf") {
await pickPdf(setPdfUri);
} else {
// Mode fleksibel: tampilkan pilihan
Alert.alert(
"Pilih Jenis File",
"Pilih sumber file yang ingin diunggah:",
[
{ text: "Batal", style: "cancel" },
{ text: "Dokumen (PDF)", onPress: () => pickPdf(setPdfUri) },
{ text: "Gambar", onPress: () => pickImage(setImageUri, aspectRatio) },
],
{ cancelable: true }
);
// Alert.alert(
// "Pilih Jenis File",
// "Pilih sumber file yang ingin diunggah:",
// [
// { text: "Batal", style: "cancel" },
// { text: "Dokumen (PDF)", onPress: () => pickPdf(setPdfUri) },
// { text: "Gambar", onPress: () => pickImage(setImageUri, aspectRatio) },
// ],
// { cancelable: true }
// );
if (Platform.OS === "android") {
Alert.alert(
"Pilih Jenis File",
"Pilih sumber file yang ingin diunggah:",
[
{ text: "Batal", style: "cancel" },
{ text: "Dokumen (PDF)", onPress: () => pickPdf(setPdfUri) },
{ text: "Gambar", onPress: () => showAspectRatioChoice(setImageUri) },
],
{ cancelable: true }
);
} else {
// iOS: Langsung pakai default [4,3] untuk gambar
Alert.alert(
"Pilih Jenis File",
"Pilih sumber file yang ingin diunggah:",
[
{ text: "Batal", style: "cancel" },
{ text: "Dokumen (PDF)", onPress: () => pickPdf(setPdfUri) },
{ text: "Gambar", onPress: () => pickImage(setImageUri, [4, 3]) },
],
{ cancelable: true }
);
}
}
}