Compare commits

...

10 Commits

Author SHA1 Message Date
cb2a57ee8e feat: tambah versi aplikasi di bagian bawah halaman setting
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 17:29:38 +08:00
f3b677f847 fix: ganti pesan error boundary dengan teks yang lebih ramah pengguna
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 11:26:33 +08:00
6ffe599ad0 docs: pecah CLAUDE.md jadi file terpisah di docs/
Pindahkan konten architecture dan conventions ke docs/ARCHITECTURE.md
dan docs/CONVENTIONS.md, lalu referensikan via @path di CLAUDE.md
agar file tetap ramping.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 11:19:49 +08:00
4a92def490 Merge pull request 'amalia/22-apr-26' (#40) from amalia/22-apr-26 into join
Reviewed-on: #40
2026-04-22 17:30:00 +08:00
0bad792ce8 feat: tambah pengecekan villageIsActive saat desa dinonaktifkan
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 16:42:59 +08:00
6c9623954c feat: tambah pengecekan isActive dan bersihkan cache saat logout
- Tampilkan Alert jika admin menonaktifkan akun user yang sedang login
- Clear React Query cache saat signOut agar data akun lama tidak bocor ke sesi berikutnya

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 15:17:21 +08:00
f39d5a4c85 Merge pull request 'feat: tambah error logger ke monitoring dashboard dengan offline queue' (#39) from amalia/21-apr-26 into join
Reviewed-on: #39
2026-04-21 17:32:08 +08:00
6021d17b5a feat: tambah error logger ke monitoring dashboard dengan offline queue
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 15:25:10 +08:00
2d86a77a48 Merge pull request 'amalia/20-apr-26' (#38) from amalia/20-apr-26 into join
Reviewed-on: #38
2026-04-20 17:49:34 +08:00
887e787a99 Merge pull request 'amalia/17-apr-26' (#37) from amalia/17-apr-26 into join
Reviewed-on: #37
2026-04-17 17:40:04 +08:00
12 changed files with 209 additions and 44 deletions

View File

@@ -26,47 +26,8 @@ bunx jest path/to/test.tsx --no-coverage
## Architecture
### Routing (Expo Router — file-based)
- `app/index.tsx` — Login/splash (public); OTP verification is handled inline via `components/auth/viewVerification.tsx` (not a separate route)
- `app/(application)/` — All authenticated screens; Expo Router enforces auth guard here
- Deep-link navigation is handled by `lib/pushToPage.ts`, which maps notification payloads to routes
### State Management (three layers)
1. **Context** (`providers/`) — Auth (token encryption/decryption via CryptoES.AES), Theme (light/dark, persisted to AsyncStorage), and React Query client
2. **Redux Toolkit** (`lib/store.ts` + slices) — Feature-level state for CRUD operations. Slices follow a naming pattern: `*Slice.ts` for read state, `*Update.ts`/`*Create.ts` for mutation state
3. **TanStack React Query** — All server data fetching; configured with 5-min stale time, 24-hour cache retention, 2 retries, and AsyncStorage persistence for offline support
### API Layer (`lib/api.ts`)
Single 773-line file defining 50+ Axios-based endpoints. The Axios instance reads `baseURL` from `Constants.expoConfig.extra.URL_API` (set in `.env` via `app.config.js`). Authentication uses Bearer tokens in headers. File uploads use `FormData` with `multipart/form-data`.
Three separate backend services are integrated:
- **REST API** (axios) — main business logic
- **WhatsApp server** — OTP delivery (separate token in `.env`)
- **Firebase** — real-time database (`lib/firebaseDatabase.ts`) and push notifications (`lib/useNotification.ts`, `lib/registerForPushNotificationsAsync.ts`)
### Providers Initialization Order
`app/_layout.tsx` wraps the app in: `ErrorBoundary``NotifierWrapper``ThemeProvider``QueryProvider``AuthProvider` → navigation stack. Redux `store` is provided inside `app/(application)/_layout.tsx`, not at the root.
### Error Boundary
`components/ErrorBoundary.tsx` is a class component (required by React) wrapping the entire app. It uses React Native's built-in `Text`**do not replace it with the custom `components/Text.tsx`** as that pulls in `ThemeProvider``AsyncStorage`, which breaks Jest tests.
Tests for ErrorBoundary live in `__tests__/ErrorBoundary-test.tsx` and use `@testing-library/react-native`.
See @docs/ARCHITECTURE.md
## Key Conventions
**Imports:** Use `@/` alias (maps to project root, configured in `tsconfig.json`). Never use relative paths like `../../`.
**Utility functions:** Prefixed with `fun_` (e.g., `lib/fun_stringToDate.ts`, `lib/fun_validateName.ts`).
**Styling:** Use theme-aware colors from `useTheme()` hook. Global `StyleSheet` definitions live in `constants/Styles.ts`. Color tokens are in `constants/Colors.ts` with explicit `light`/`dark` variants.
**Component structure:** Feature-specific subdirectories under `components/` (e.g., `components/announcement/`) typically contain a header component alongside list/card components for that feature.
**Environment config:** All env vars are declared in `.env`, exposed through `app.config.js` `extra` field, and accessed via `Constants.expoConfig.extra.*` or the `constants/ConstEnv.ts` wrapper.
**EAS builds:** Profiles are `development`, `preview`, and `production` in `eas.json`. Production builds auto-increment the app version via the `bump` script.
See @docs/CONVENTIONS.md

View File

@@ -85,6 +85,8 @@ export default {
FIREBASE_STORAGE_BUCKET: process.env.FIREBASE_STORAGE_BUCKET,
FIREBASE_MESSAGING_SENDER_ID: process.env.FIREBASE_MESSAGING_SENDER_ID,
FIREBASE_APP_ID: process.env.FIREBASE_APP_ID,
URL_MONITORING: process.env.URL_MONITORING,
KEY_API_MONITORING: process.env.KEY_API_MONITORING,
}
}
};

View File

@@ -16,7 +16,7 @@ import { useQuery, useQueryClient } from "@tanstack/react-query";
import { LinearGradient } from "expo-linear-gradient";
import { Stack } from "expo-router";
import { useEffect, useState } from "react";
import { Dimensions, Platform, RefreshControl, SafeAreaView, ScrollView, View } from "react-native";
import { Alert, Dimensions, Platform, RefreshControl, SafeAreaView, ScrollView, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useDispatch, useSelector } from "react-redux";
@@ -55,6 +55,26 @@ export default function Home() {
}
}, [isError, signOut])
useEffect(() => {
if (profile && profile.isActive === false) {
Alert.alert(
'Akun Dinonaktifkan',
'Akun kamu telah dinonaktifkan. Silahkan hubungi admin untuk informasi lebih lanjut.',
[{ text: 'OK', onPress: signOut }]
)
}
}, [profile, signOut])
useEffect(() => {
if (profile && profile.villageIsActive === false) {
Alert.alert(
'Desa Dinonaktifkan',
'Desa kamu saat ini telah dinonaktifkan. Silahkan hubungi pengelola sistem untuk informasi lebih lanjut.',
[{ text: 'OK', onPress: signOut }]
)
}
}, [profile, signOut])
const handleRefresh = async () => {
setRefreshing(true)
// Invalidate all queries related to the home screen

View File

@@ -9,6 +9,7 @@ import { useAuthSession } from "@/providers/AuthProvider";
import { useTheme } from "@/providers/ThemeProvider";
import { Feather, Ionicons } from "@expo/vector-icons";
import AsyncStorage from "@react-native-async-storage/async-storage";
import Constants from "expo-constants";
import { router } from "expo-router";
import { useCallback, useEffect, useRef, useState } from "react";
import { AppState, AppStateStatus, Pressable, View } from "react-native";
@@ -196,6 +197,10 @@ export default function ListSetting() {
<ThemeOption label="Gelap" value="dark" icon="moon-outline" />
<ThemeOption label="Sistem" value="system" icon="phone-portrait-outline" />
</DrawerBottom>
<Text style={{ color: colors.icon, textAlign: 'center', marginTop: 'auto', fontSize: 12 }}>
Versi {Constants.expoConfig?.version}
</Text>
</View>
)
}

View File

@@ -2,11 +2,13 @@ import AuthProvider from '@/providers/AuthProvider';
import ThemeProvider, { useTheme } from '@/providers/ThemeProvider';
import QueryProvider from '@/providers/QueryProvider';
import ErrorBoundary from '@/components/ErrorBoundary';
import { flushErrorQueue } from '@/lib/errorLogger';
import { useFonts } from 'expo-font';
import { Stack } from 'expo-router';
import * as SplashScreen from 'expo-splash-screen';
import { StatusBar } from 'expo-status-bar';
import { useEffect } from 'react';
import { AppState } from 'react-native';
import { GestureHandlerRootView } from 'react-native-gesture-handler';
import { NotifierWrapper } from 'react-native-notifier';
import 'react-native-reanimated';
@@ -38,9 +40,17 @@ export default function RootLayout() {
useEffect(() => {
if (loaded) {
SplashScreen.hideAsync();
flushErrorQueue();
}
}, [loaded]);
useEffect(() => {
const sub = AppState.addEventListener('change', (state) => {
if (state === 'active') flushErrorQueue();
});
return () => sub.remove();
}, []);
if (!loaded) {
return null;
}

View File

@@ -18,6 +18,11 @@ export default class ErrorBoundary extends Component<Props, State> {
return { hasError: true, error };
}
componentDidCatch(error: Error): void {
const { logError } = require('@/lib/errorLogger');
logError(error.message, error);
}
handleReset = () => {
this.setState({ hasError: false, error: null });
};
@@ -29,7 +34,7 @@ export default class ErrorBoundary extends Component<Props, State> {
<View style={styles.container}>
<Text style={styles.title}>Terjadi Kesalahan</Text>
<Text style={styles.message}>
{this.state.error?.message ?? 'Kesalahan tidak diketahui'}
Silahkan coba lagi beberapa saat lagi atau hubungi admin untuk bantuan.
</Text>
<TouchableOpacity style={styles.button} onPress={this.handleReset}>
<Text style={styles.buttonText}>Coba Lagi</Text>

View File

@@ -3,6 +3,8 @@ import Constants from 'expo-constants';
export const ConstEnv = {
url_storage: Constants?.expoConfig?.extra?.URL_STORAGE,
pass_encrypt: Constants?.expoConfig?.extra?.PASS_ENC,
url_monitoring: Constants?.expoConfig?.extra?.URL_MONITORING,
key_api_monitoring: Constants?.expoConfig?.extra?.KEY_API_MONITORING,
firebase: {
apiKey: Constants?.expoConfig?.extra?.FIREBASE_API_KEY,
authDomain: Constants?.expoConfig?.extra?.FIREBASE_AUTH_DOMAIN,

32
docs/ARCHITECTURE.md Normal file
View File

@@ -0,0 +1,32 @@
# Architecture
## Routing (Expo Router — file-based)
- `app/index.tsx` — Login/splash (public); OTP verification is handled inline via `components/auth/viewVerification.tsx` (not a separate route)
- `app/(application)/` — All authenticated screens; Expo Router enforces auth guard here
- Deep-link navigation is handled by `lib/pushToPage.ts`, which maps notification payloads to routes
## State Management (three layers)
1. **Context** (`providers/`) — Auth (token encryption/decryption via CryptoES.AES), Theme (light/dark, persisted to AsyncStorage), and React Query client
2. **Redux Toolkit** (`lib/store.ts` + slices) — Feature-level state for CRUD operations. Slices follow a naming pattern: `*Slice.ts` for read state, `*Update.ts`/`*Create.ts` for mutation state
3. **TanStack React Query** — All server data fetching; configured with 5-min stale time, 24-hour cache retention, 2 retries, and AsyncStorage persistence for offline support
## API Layer (`lib/api.ts`)
Single file defining 50+ Axios-based endpoints. The Axios instance reads `baseURL` from `Constants.expoConfig.extra.URL_API` (set in `.env` via `app.config.js`). Authentication uses Bearer tokens in headers. File uploads use `FormData` with `multipart/form-data`.
Three separate backend services are integrated:
- **REST API** (axios) — main business logic
- **WhatsApp server** — OTP delivery (separate token in `.env`)
- **Firebase** — real-time database (`lib/firebaseDatabase.ts`) and push notifications (`lib/useNotification.ts`, `lib/registerForPushNotificationsAsync.ts`)
## Providers Initialization Order
`app/_layout.tsx` wraps the app in: `ErrorBoundary``NotifierWrapper``ThemeProvider``QueryProvider``AuthProvider` → navigation stack. Redux `store` is provided inside `app/(application)/_layout.tsx`, not at the root.
## Error Boundary
`components/ErrorBoundary.tsx` is a class component (required by React) wrapping the entire app. It uses React Native's built-in `Text`**do not replace it with the custom `components/Text.tsx`** as that pulls in `ThemeProvider``AsyncStorage`, which breaks Jest tests.
Tests for ErrorBoundary live in `__tests__/ErrorBoundary-test.tsx` and use `@testing-library/react-native`.

13
docs/CONVENTIONS.md Normal file
View File

@@ -0,0 +1,13 @@
# Key Conventions
**Imports:** Use `@/` alias (maps to project root, configured in `tsconfig.json`). Never use relative paths like `../../`.
**Utility functions:** Prefixed with `fun_` (e.g., `lib/fun_stringToDate.ts`, `lib/fun_validateName.ts`).
**Styling:** Use theme-aware colors from `useTheme()` hook. Global `StyleSheet` definitions live in `constants/Styles.ts`. Color tokens are in `constants/Colors.ts` with explicit `light`/`dark` variants.
**Component structure:** Feature-specific subdirectories under `components/` (e.g., `components/announcement/`) typically contain a header component alongside list/card components for that feature.
**Environment config:** All env vars are declared in `.env`, exposed through `app.config.js` `extra` field, and accessed via `Constants.expoConfig.extra.*` or the `constants/ConstEnv.ts` wrapper.
**EAS builds:** Profiles are `development`, `preview`, and `production` in `eas.json`. Production builds auto-increment the app version via the `bump` script.

View File

@@ -1,10 +1,22 @@
import axios from 'axios';
import axios, { AxiosError } from 'axios';
import Constants from 'expo-constants';
import { logError } from '@/lib/errorLogger';
const api = axios.create({
baseURL: Constants?.expoConfig?.extra?.URL_API
});
api.interceptors.response.use(
(response) => response,
(error: AxiosError) => {
const status = error.response?.status;
const url = error.config?.url ?? 'unknown endpoint';
const description = `API error ${status ?? 'network'} on ${url}`;
logError(description, error);
return Promise.reject(error);
}
);
export const apiCheckPhoneLogin = async (body: { phone: string }) => {
const response = await api.post('/auth/login', body)
return response.data;

100
lib/errorLogger.ts Normal file
View File

@@ -0,0 +1,100 @@
import AsyncStorage from '@react-native-async-storage/async-storage';
import NetInfo from '@react-native-community/netinfo';
import Constants from 'expo-constants';
import * as Device from 'expo-device';
import { Platform } from 'react-native';
import { ConstEnv } from '@/constants/ConstEnv';
const QUEUE_KEY = 'error_log_queue';
const APP_NAME = 'desa-plus';
type ErrorPayload = {
affectedVersion: string;
device: string;
os: string;
description: string;
app: string;
source: 'SYSTEM';
stackTrace: string;
};
function buildPayload(description: string, stackTrace?: string): ErrorPayload {
const platformName = Platform.OS === 'ios' ? 'iOS' : 'Android';
return {
affectedVersion: Constants.expoConfig?.version ?? 'unknown',
device: Device.modelName ?? 'unknown',
os: `${platformName} ${Device.osVersion ?? ''}`.trim(),
description,
app: APP_NAME,
source: 'SYSTEM',
stackTrace: stackTrace ?? '',
};
}
async function sendToMonitoring(payload: ErrorPayload): Promise<boolean> {
try {
const res = await fetch(`${ConstEnv.url_monitoring}/api/bugs`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-API-Key': ConstEnv.key_api_monitoring ?? '',
},
body: JSON.stringify(payload),
});
return res.ok;
} catch (error) {
console.log(error);
return false;
}
}
async function enqueue(payload: ErrorPayload): Promise<void> {
try {
const raw = await AsyncStorage.getItem(QUEUE_KEY);
const queue: ErrorPayload[] = raw ? JSON.parse(raw) : [];
queue.push(payload);
await AsyncStorage.setItem(QUEUE_KEY, JSON.stringify(queue));
} catch {}
}
export async function flushErrorQueue(): Promise<void> {
try {
const state = await NetInfo.fetch();
if (!state.isConnected) return;
const raw = await AsyncStorage.getItem(QUEUE_KEY);
if (!raw) return;
const queue: ErrorPayload[] = JSON.parse(raw);
if (queue.length === 0) return;
const failed: ErrorPayload[] = [];
for (const payload of queue) {
const ok = await sendToMonitoring(payload);
if (!ok) failed.push(payload);
}
if (failed.length > 0) {
await AsyncStorage.setItem(QUEUE_KEY, JSON.stringify(failed));
} else {
await AsyncStorage.removeItem(QUEUE_KEY);
}
} catch {}
}
export async function logError(description: string, error?: Error | unknown): Promise<void> {
const stackTrace = error instanceof Error ? (error.stack ?? error.message) : String(error ?? '');
const payload = buildPayload(description, stackTrace);
try {
const state = await NetInfo.fetch();
if (state.isConnected) {
const ok = await sendToMonitoring(payload);
if (!ok) await enqueue(payload);
} else {
await enqueue(payload);
}
} catch {
await enqueue(payload);
}
}

View File

@@ -2,6 +2,7 @@ import { ConstEnv } from '@/constants/ConstEnv';
import { apiRegisteredToken, apiUnregisteredToken } from '@/lib/api';
import { getToken } from '@/lib/useNotification';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { useQueryClient } from '@tanstack/react-query';
import CryptoES from "crypto-es";
import { router } from "expo-router";
import { createContext, MutableRefObject, ReactNode, useCallback, useContext, useEffect, useRef, useState } from 'react';
@@ -30,6 +31,7 @@ export function useAuthSession() {
export default function AuthProvider({ children }: { children: ReactNode }): ReactNode {
const tokenRef = useRef<string | null>(null);
const [isLoading, setIsLoading] = useState(true);
const queryClient = useQueryClient();
useEffect(() => {
(async (): Promise<void> => {
@@ -87,6 +89,7 @@ export default function AuthProvider({ children }: { children: ReactNode }): Rea
} finally {
await AsyncStorage.setItem('@token', '');
tokenRef.current = null;
queryClient.clear();
router.replace('/');
}
}, []);