@fadyshawky/react-native-magic 2.2.0 → 2.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +90 -55
- package/index.js +4 -0
- package/package.json +9 -3
- package/scripts/askPackageName.js +10 -5
- package/template/.env.development +8 -6
- package/template/.env.example +15 -5
- package/template/.env.production +8 -6
- package/template/.env.staging +8 -6
- package/template/.eslintrc.js +14 -0
- package/template/.husky/pre-commit +1 -0
- package/template/App.tsx +47 -16
- package/template/__tests__/App.test.tsx +28 -10
- package/template/babel.config.js +20 -1
- package/template/docs/ARCHITECTURE.md +40 -10
- package/template/docs/BEST_PRACTICES.md +10 -1
- package/template/docs/CUSTOMIZATION.md +118 -5
- package/template/docs/design-system.html +1164 -0
- package/template/docs/wireframes.html +411 -0
- package/template/index.js +10 -0
- package/template/jest.config.js +16 -1
- package/template/jest.setup.js +61 -0
- package/template/package-lock.json +12178 -8293
- package/template/package.json +53 -19
- package/template/react-native.config.js +3 -0
- package/template/resources/fonts/.gitkeep +0 -0
- package/template/scripts/ci-sync-env.cjs +71 -0
- package/template/src/assets/brand/logo-mark.svg +8 -0
- package/template/src/assets/brand/logo-mono.svg +9 -0
- package/template/src/assets/brand/logo-primary.svg +15 -0
- package/template/src/assets/brand/wordmark-dark.svg +18 -0
- package/template/src/common/components/AppBottomSheet.tsx +87 -0
- package/template/src/common/components/AppSwitch.tsx +75 -0
- package/template/src/common/components/AppTextInput.tsx +161 -0
- package/template/src/common/components/Avatar.tsx +75 -0
- package/template/src/common/components/Badge.tsx +66 -0
- package/template/src/common/components/CardScroller.tsx +58 -0
- package/template/src/common/components/Cards.tsx +13 -7
- package/template/src/common/components/Carousel.tsx +196 -0
- package/template/src/common/components/Checkbox.tsx +85 -0
- package/template/src/common/components/Chip.tsx +55 -0
- package/template/src/common/components/Dropdown.tsx +202 -0
- package/template/src/common/components/ErrorBoundary.tsx +82 -0
- package/template/src/common/components/FlatListWrapper.tsx +8 -8
- package/template/src/common/components/ListItem.tsx +90 -0
- package/template/src/common/components/LoadingComponent.tsx +8 -2
- package/template/src/common/components/Logo.tsx +77 -0
- package/template/src/common/components/ModalDialog.tsx +141 -0
- package/template/src/common/components/NetworkBanner.tsx +47 -0
- package/template/src/common/components/OTPInput.tsx +0 -1
- package/template/src/common/components/PrimaryButton.tsx +0 -14
- package/template/src/common/components/PrimaryTextInput.tsx +66 -130
- package/template/src/common/components/RadioGroup.tsx +95 -0
- package/template/src/common/components/SafeText.tsx +4 -3
- package/template/src/common/components/SearchBar.tsx +7 -5
- package/template/src/common/components/SegmentedControl.tsx +77 -0
- package/template/src/common/components/Skeleton.tsx +47 -0
- package/template/src/common/components/TryAgain.tsx +4 -2
- package/template/src/common/helpers/arrayHelpers.ts +2 -2
- package/template/src/common/helpers/defaultKeyIdExtractor.ts +1 -1
- package/template/src/common/helpers/regexHelpers.ts +1 -2
- package/template/src/common/helpers/stringsHelpers.ts +0 -1
- package/template/src/common/hooks/useBackHandler.ts +5 -2
- package/template/src/common/hooks/useEventRegister.ts +1 -1
- package/template/src/common/hooks/useFlatListActions.ts +1 -1
- package/template/src/common/hooks/useWhyDidYouUpdate.ts +1 -1
- package/template/src/common/localization/LocalizationProvider.tsx +1 -1
- package/template/src/common/localization/RTLInitializer.tsx +1 -1
- package/template/src/common/localization/dateFormatter.ts +0 -1
- package/template/src/common/localization/intlFormatter.ts +0 -1
- package/template/src/common/localization/localization.ts +2 -2
- package/template/src/common/localization/translations/homeLocalization.ts +14 -0
- package/template/src/common/localization/translations/loginLocalization.ts +8 -0
- package/template/src/common/localization/translations/mainNavigationLocalization.ts +2 -0
- package/template/src/common/localization/translations/profileLocalization.ts +16 -0
- package/template/src/common/utils/index.tsx +0 -6
- package/template/src/common/validations/commonValidations.ts +2 -2
- package/template/src/core/api/errorHandler.ts +1 -1
- package/template/src/core/api/responseHandlers.ts +1 -3
- package/template/src/core/api/serverHeaders.ts +61 -12
- package/template/src/core/notifications/notificationAuth.ts +6 -0
- package/template/src/core/notifications/notificationService.ts +125 -0
- package/template/src/core/notifications/routeFromNotificationData.ts +32 -0
- package/template/src/core/store/categories/categoriesActions.ts +25 -0
- package/template/src/core/store/categories/categoriesSlice.ts +51 -0
- package/template/src/core/store/categories/categoriesState.ts +19 -0
- package/template/src/core/store/rootReducer.ts +2 -0
- package/template/src/core/store/store.tsx +6 -1
- package/template/src/core/store/user/userActions.ts +75 -14
- package/template/src/core/store/user/userSlice.ts +49 -26
- package/template/src/core/store/user/userState.ts +6 -4
- package/template/src/core/theme/ThemeProvider.tsx +5 -3
- package/template/src/core/theme/brand.ts +50 -0
- package/template/src/core/theme/colors.ts +113 -99
- package/template/src/core/theme/commonConsts.ts +2 -2
- package/template/src/core/theme/commonStyles.ts +1 -1
- package/template/src/core/theme/themes.ts +2 -0
- package/template/src/core/theme/types.ts +4 -2
- package/template/src/core/utils/stringUtils.ts +1 -1
- package/template/src/design-system/index.ts +2 -0
- package/template/src/design-system/tokens/brand.ts +6 -0
- package/template/src/design-system/tokens/index.ts +3 -0
- package/template/src/design-system/tokens/palette.ts +4 -0
- package/template/src/design-system/tokens/typography-spacing.ts +2 -0
- package/template/src/navigation/AuthStack.tsx +1 -4
- package/template/src/navigation/HeaderComponents.tsx +6 -3
- package/template/src/navigation/MainStack.tsx +18 -6
- package/template/src/navigation/RootNavigation.tsx +4 -7
- package/template/src/navigation/TabBar.tsx +7 -6
- package/template/src/navigation/types.ts +10 -31
- package/template/src/screens/Login/Login.tsx +47 -47
- package/template/src/screens/OTP/OTPScreen.tsx +6 -9
- package/template/src/screens/components/ComponentsScreen.tsx +301 -0
- package/template/src/screens/home/HomeScreen.tsx +143 -1
- package/template/src/screens/home/hooks/useHomeData.ts +19 -5
- package/template/src/screens/index.tsx +1 -0
- package/template/src/screens/profile/Profile.tsx +139 -2
- package/template/src/screens/splash/Splash.tsx +44 -11
- package/template/src/sheetManager/sheets.tsx +1 -1
- package/template/tsconfig.json +14 -2
- package/template/types/globals.d.ts +43 -0
- package/template/types/index.ts +2 -6
- package/template/types/modules.d.ts +9 -0
- package/template/types/react-native-config.d.ts +0 -2
- package/.vscode/settings.json +0 -8
- package/CHANGELOG.md +0 -119
- package/CODE_OF_CONDUCT.md +0 -83
- package/CONTRIBUTING.md +0 -60
- package/local.properties +0 -1
- package/template/src/common/components/ImageCropPickerButton.tsx +0 -107
- package/template/src/common/components/PhotoTakingButton.tsx +0 -94
- package/template/src/common/helpers/imageHelpers.ts +0 -5
- package/template/src/common/helpers/inAppReviewHelper.ts +0 -30
- package/template/src/common/helpers/orientationHelpers.ts +0 -25
- package/template/src/common/helpers/shareHelpers.ts +0 -47
- package/template/src/common/utils/FeesCaalculation.tsx +0 -37
- package/template/src/common/utils/printData.tsx +0 -161
- package/template/src/common/validations/examples/TextInputWithValidation.tsx +0 -229
|
@@ -4,7 +4,7 @@ export function getItemIndex<T, V>(
|
|
|
4
4
|
comparisonValue: V,
|
|
5
5
|
) {
|
|
6
6
|
const index = data.findIndex(
|
|
7
|
-
item => item[comparisonParam]
|
|
7
|
+
item => item[comparisonParam] === comparisonValue,
|
|
8
8
|
);
|
|
9
9
|
|
|
10
10
|
const itemExists = index > -1;
|
|
@@ -17,7 +17,7 @@ export function filterBySearch<T>(
|
|
|
17
17
|
param: keyof T,
|
|
18
18
|
searchText: string,
|
|
19
19
|
): T[] {
|
|
20
|
-
if (searchText
|
|
20
|
+
if (searchText !== '') {
|
|
21
21
|
const lowerCaseSearch = searchText.toLowerCase();
|
|
22
22
|
|
|
23
23
|
return data.filter(item =>
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
export function isEmail(email: string): boolean {
|
|
2
2
|
const emailRegex =
|
|
3
|
-
|
|
4
|
-
/^(([^<>()\[\]\\.,;:\s@"]+(\.[^<>()\[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/;
|
|
3
|
+
/^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/;
|
|
5
4
|
|
|
6
5
|
return emailRegex.test(email);
|
|
7
6
|
}
|
|
@@ -8,7 +8,6 @@ export function removeHtmlTags(text: string): string {
|
|
|
8
8
|
|
|
9
9
|
export function removeEmojis(text: string): string {
|
|
10
10
|
return text.replace(
|
|
11
|
-
// eslint-disable-next-line max-len
|
|
12
11
|
/(?:[\u2700-\u27bf]|(?:\ud83c[\udde6-\uddff]){2}|[\ud800-\udbff][\udc00-\udfff]|[\u0023-\u0039]\ufe0f?\u20e3|\u3299|\u3297|\u303d|\u3030|\u24c2|\ud83c[\udd70-\udd71]|\ud83c[\udd7e-\udd7f]|\ud83c\udd8e|\ud83c[\udd91-\udd9a]|\ud83c[\udde6-\uddff]|[\ud83c\ude01-\ude02]|\ud83c\ude1a|\ud83c\ude2f|[\ud83c\ude32-\ude3a]|[\ud83c\ude50-\ude51]|\u203c|\u2049|[\u25aa-\u25ab]|\u25b6|\u25c0|[\u25fb-\u25fe]|\u00a9|\u00ae|\u2122|\u2139|\ud83c\udc04|[\u2600-\u26FF]|\u2b05|\u2b06|\u2b07|\u2b1b|\u2b1c|\u2b50|\u2b55|\u231a|\u231b|\u2328|\u23cf|[\u23e9-\u23f3]|[\u23f8-\u23fa]|\ud83c\udccf|\u2934|\u2935|[\u2190-\u21ff])/g,
|
|
13
12
|
'',
|
|
14
13
|
);
|
|
@@ -3,8 +3,11 @@ import {BackHandler} from 'react-native';
|
|
|
3
3
|
|
|
4
4
|
export function useBackHandler(handler: () => boolean, deps?: DependencyList) {
|
|
5
5
|
useEffect(() => {
|
|
6
|
-
BackHandler.addEventListener(
|
|
6
|
+
const subscription = BackHandler.addEventListener(
|
|
7
|
+
'hardwareBackPress',
|
|
8
|
+
handler,
|
|
9
|
+
);
|
|
7
10
|
|
|
8
|
-
return () =>
|
|
11
|
+
return () => subscription.remove();
|
|
9
12
|
}, [deps, handler]);
|
|
10
13
|
}
|
|
@@ -16,7 +16,7 @@ export function useFlatListActions(
|
|
|
16
16
|
const dispatch = useAppDispatch();
|
|
17
17
|
|
|
18
18
|
const loadMore = useCallback(() => {
|
|
19
|
-
loadState
|
|
19
|
+
loadState === LoadState.idle && dispatch(request(LoadState.loadingMore));
|
|
20
20
|
}, [dispatch, request, loadState]);
|
|
21
21
|
|
|
22
22
|
const tryAgain = useCallback(() => {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import React, {useEffect, useState} from 'react';
|
|
2
|
-
import {I18nManager
|
|
2
|
+
import {I18nManager} from 'react-native';
|
|
3
3
|
import {useAppSelector} from '../../core/store/reduxHelpers';
|
|
4
4
|
import {Languages} from './localization';
|
|
5
5
|
|
|
@@ -67,5 +67,5 @@ export function setLanguage(language?: Languages): void {
|
|
|
67
67
|
setDateLocale(localizationLanguage);
|
|
68
68
|
}
|
|
69
69
|
|
|
70
|
-
// Default language is Arabic
|
|
71
|
-
export const DEFAULT_LANGUAGE = Languages.
|
|
70
|
+
// Default language is English (LTR). Arabic/RTL stays available and switchable.
|
|
71
|
+
export const DEFAULT_LANGUAGE = Languages.en;
|
|
@@ -2,9 +2,23 @@ export const homeLocalization = {
|
|
|
2
2
|
en: {
|
|
3
3
|
welcome: 'Welcome',
|
|
4
4
|
title: 'Home',
|
|
5
|
+
greeting: 'Hi,',
|
|
6
|
+
there: 'there',
|
|
7
|
+
heroEyebrow: 'GET STARTED',
|
|
8
|
+
heroTitle: 'Build something great',
|
|
9
|
+
heroSubtitle: 'Your starter is wired and ready to ship.',
|
|
10
|
+
explore: 'Explore',
|
|
11
|
+
items: 'Items',
|
|
5
12
|
},
|
|
6
13
|
ar: {
|
|
7
14
|
welcome: 'مرحباً',
|
|
8
15
|
title: 'الرئيسية',
|
|
16
|
+
greeting: 'مرحباً،',
|
|
17
|
+
there: 'صديقي',
|
|
18
|
+
heroEyebrow: 'ابدأ الآن',
|
|
19
|
+
heroTitle: 'اصنع شيئاً رائعاً',
|
|
20
|
+
heroSubtitle: 'القالب جاهز ومُهيّأ للانطلاق.',
|
|
21
|
+
explore: 'استكشف',
|
|
22
|
+
items: 'العناصر',
|
|
9
23
|
},
|
|
10
24
|
};
|
|
@@ -3,11 +3,15 @@ export const loginLocalization = {
|
|
|
3
3
|
Login: 'Login',
|
|
4
4
|
welcome: 'Welcome Back',
|
|
5
5
|
welcome_description: 'Sign in to your account to continue',
|
|
6
|
+
signIn: 'Sign in',
|
|
7
|
+
needHelp: 'Need help?',
|
|
8
|
+
phoneOrEmail: 'Phone or email',
|
|
6
9
|
Email: 'Email',
|
|
7
10
|
EnterEmail: 'Enter your email',
|
|
8
11
|
Password: 'Password',
|
|
9
12
|
EnterPassword: 'Enter your password',
|
|
10
13
|
PhoneNumber: 'Phone Number',
|
|
14
|
+
EnterPhone: 'Enter your phone number',
|
|
11
15
|
forgetPassword: 'Forgot Password?',
|
|
12
16
|
continue: 'Continue',
|
|
13
17
|
notMember: 'Not a member?',
|
|
@@ -34,12 +38,16 @@ export const loginLocalization = {
|
|
|
34
38
|
Login: 'تسجيل الدخول',
|
|
35
39
|
welcome: 'مرحباً بك',
|
|
36
40
|
welcome_description: 'سجل دخولك للمتابعة',
|
|
41
|
+
signIn: 'تسجيل الدخول',
|
|
42
|
+
needHelp: 'تحتاج مساعدة؟',
|
|
43
|
+
phoneOrEmail: 'الهاتف أو البريد',
|
|
37
44
|
identification_number: 'رقم الهوية',
|
|
38
45
|
Email: 'البريد الإلكتروني',
|
|
39
46
|
EnterEmail: 'أدخل بريدك الإلكتروني',
|
|
40
47
|
Password: 'كلمة المرور',
|
|
41
48
|
EnterPassword: 'أدخل كلمة المرور',
|
|
42
49
|
PhoneNumber: 'رقم الهاتف',
|
|
50
|
+
EnterPhone: 'أدخل رقم الهاتف',
|
|
43
51
|
EnterPhoneNumber: 'أدخل رقم الهاتف',
|
|
44
52
|
Country: 'الدولة',
|
|
45
53
|
EnterCountry: 'أدخل الدولة',
|
|
@@ -2,6 +2,7 @@ export const mainNavigationLocalization = {
|
|
|
2
2
|
en: {
|
|
3
3
|
tabs: {
|
|
4
4
|
Main: 'Home',
|
|
5
|
+
Components: 'Components',
|
|
5
6
|
Favorites: 'Favorites',
|
|
6
7
|
Financials: 'Financials',
|
|
7
8
|
Account: 'Account',
|
|
@@ -16,6 +17,7 @@ export const mainNavigationLocalization = {
|
|
|
16
17
|
ar: {
|
|
17
18
|
tabs: {
|
|
18
19
|
Main: 'الرئيسية',
|
|
20
|
+
Components: 'المكوّنات',
|
|
19
21
|
Favorites: 'المفضلة',
|
|
20
22
|
Financials: 'المالية',
|
|
21
23
|
Account: 'الحساب',
|
|
@@ -6,6 +6,14 @@ export const profileLocalization = {
|
|
|
6
6
|
darkMode: 'Dark Mode',
|
|
7
7
|
language: 'Language',
|
|
8
8
|
account: 'Account',
|
|
9
|
+
fullName: 'Full name',
|
|
10
|
+
mobileNumber: 'Mobile number',
|
|
11
|
+
appearance: 'Appearance',
|
|
12
|
+
light: 'Light',
|
|
13
|
+
dark: 'Dark',
|
|
14
|
+
logoutConfirm: 'Are you sure you want to log out?',
|
|
15
|
+
notifications: 'Notifications',
|
|
16
|
+
system: 'System',
|
|
9
17
|
editProfile: 'Edit Profile',
|
|
10
18
|
changePassword: 'Change Password',
|
|
11
19
|
logout: 'Logout',
|
|
@@ -20,6 +28,14 @@ export const profileLocalization = {
|
|
|
20
28
|
darkMode: 'الوضع الداكن',
|
|
21
29
|
language: 'اللغة',
|
|
22
30
|
account: 'الحساب',
|
|
31
|
+
fullName: 'الاسم الكامل',
|
|
32
|
+
mobileNumber: 'رقم الهاتف',
|
|
33
|
+
appearance: 'المظهر',
|
|
34
|
+
light: 'فاتح',
|
|
35
|
+
dark: 'داكن',
|
|
36
|
+
logoutConfirm: 'هل تريد تسجيل الخروج؟',
|
|
37
|
+
notifications: 'الإشعارات',
|
|
38
|
+
system: 'النظام',
|
|
23
39
|
editProfile: 'تعديل الملف الشخصي',
|
|
24
40
|
changePassword: 'تغيير كلمة المرور',
|
|
25
41
|
logout: 'تسجيل الخروج',
|
|
@@ -1,11 +1,5 @@
|
|
|
1
|
-
// Fee calculation utilities
|
|
2
|
-
export {initFees, calculateFees} from './FeesCaalculation';
|
|
3
|
-
|
|
4
1
|
// State management utility
|
|
5
2
|
export {newState} from './newState';
|
|
6
3
|
|
|
7
4
|
// Size calculation utility
|
|
8
5
|
export {createPerfectSize} from './createPerfectSize';
|
|
9
|
-
|
|
10
|
-
// Print data utility
|
|
11
|
-
export {makePrintData} from './printData';
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import {localization} from '../localization/localization';
|
|
2
2
|
|
|
3
3
|
export function emptyValidation(checkValue: string): string | null {
|
|
4
|
-
return checkValue != null && checkValue
|
|
4
|
+
return checkValue != null && checkValue !== ''
|
|
5
5
|
? null
|
|
6
6
|
: localization.errors.thisFieldIsRequired;
|
|
7
7
|
}
|
|
@@ -16,7 +16,7 @@ export function datesValidation(
|
|
|
16
16
|
const fromTime = from.getTime();
|
|
17
17
|
const toTime = to.getTime();
|
|
18
18
|
|
|
19
|
-
if (fromTime
|
|
19
|
+
if (fromTime === toTime) {
|
|
20
20
|
return localization.errors.datesCantBeEqual(fromLabel, toLabel);
|
|
21
21
|
} else {
|
|
22
22
|
const isFromTimeLater = fromTime > toTime;
|
|
@@ -1,11 +1,9 @@
|
|
|
1
1
|
import {AxiosResponse} from 'axios';
|
|
2
2
|
import Snackbar from 'react-native-snackbar';
|
|
3
|
-
import {setLogout} from '../store/user/userSlice';
|
|
4
|
-
import {store} from '../store/store';
|
|
5
3
|
|
|
6
4
|
export const handleFetchJsonResponse = (
|
|
7
5
|
response: AxiosResponse,
|
|
8
|
-
|
|
6
|
+
_showSuccessMessage?: boolean,
|
|
9
7
|
) => {
|
|
10
8
|
return response.data;
|
|
11
9
|
};
|
|
@@ -1,9 +1,15 @@
|
|
|
1
|
-
import axios, {
|
|
1
|
+
import axios, {
|
|
2
|
+
AxiosDefaults,
|
|
3
|
+
AxiosError,
|
|
4
|
+
AxiosRequestConfig,
|
|
5
|
+
AxiosResponse,
|
|
6
|
+
} from 'axios';
|
|
7
|
+
import {API_BASE_URL} from '../config';
|
|
2
8
|
import {store} from '../store/store';
|
|
9
|
+
import {refreshUserToken} from '../store/user/userActions';
|
|
3
10
|
import {setLogout} from '../store/user/userSlice';
|
|
4
|
-
import {API_BASE_URL} from '../config';
|
|
5
11
|
|
|
6
|
-
export const defaultHeaders:
|
|
12
|
+
export const defaultHeaders: Record<string, string> = {
|
|
7
13
|
Connection: 'keep-alive',
|
|
8
14
|
'Content-Type': 'application/json',
|
|
9
15
|
};
|
|
@@ -16,9 +22,8 @@ declare type MethodData = {
|
|
|
16
22
|
|
|
17
23
|
const instance = axios.create({
|
|
18
24
|
baseURL: API_BASE_URL,
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
},
|
|
25
|
+
timeout: 30000,
|
|
26
|
+
headers: {...defaultHeaders},
|
|
22
27
|
});
|
|
23
28
|
|
|
24
29
|
instance.interceptors.request.use(
|
|
@@ -35,19 +40,63 @@ instance.interceptors.request.use(
|
|
|
35
40
|
error => Promise.reject(error),
|
|
36
41
|
);
|
|
37
42
|
|
|
43
|
+
/**
|
|
44
|
+
* Dedup concurrent 401s: only one refresh request in flight at a time;
|
|
45
|
+
* other failing requests await the same promise and retry once the new
|
|
46
|
+
* token is available.
|
|
47
|
+
*/
|
|
48
|
+
let refreshPromise: Promise<string | null> | null = null;
|
|
49
|
+
|
|
50
|
+
async function runRefresh(): Promise<string | null> {
|
|
51
|
+
try {
|
|
52
|
+
const result = await store.dispatch(refreshUserToken());
|
|
53
|
+
if (refreshUserToken.fulfilled.match(result)) {
|
|
54
|
+
return result.payload.accessToken;
|
|
55
|
+
}
|
|
56
|
+
return null;
|
|
57
|
+
} catch {
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
38
62
|
instance.interceptors.response.use(
|
|
39
63
|
(response: AxiosResponse) => response,
|
|
40
64
|
async (error: AxiosError) => {
|
|
41
|
-
const originalRequest = error.config
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
65
|
+
const originalRequest = error.config as AxiosRequestConfig & {
|
|
66
|
+
_retry?: boolean;
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
const url = originalRequest?.url ?? '';
|
|
70
|
+
const isAuthEndpoint =
|
|
71
|
+
url.includes('/login') || url.includes('/auth/refresh');
|
|
72
|
+
|
|
73
|
+
if (isAuthEndpoint) {
|
|
46
74
|
return Promise.reject(error);
|
|
47
75
|
}
|
|
48
|
-
|
|
76
|
+
|
|
77
|
+
if (error.response?.status === 401 && !originalRequest._retry) {
|
|
78
|
+
originalRequest._retry = true;
|
|
79
|
+
if (!refreshPromise) {
|
|
80
|
+
refreshPromise = runRefresh().finally(() => {
|
|
81
|
+
refreshPromise = null;
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
const newToken = await refreshPromise;
|
|
85
|
+
if (newToken) {
|
|
86
|
+
originalRequest.headers = {
|
|
87
|
+
...(originalRequest.headers ?? {}),
|
|
88
|
+
Authorization: `Bearer ${newToken}`,
|
|
89
|
+
};
|
|
90
|
+
return instance.request(originalRequest);
|
|
91
|
+
}
|
|
49
92
|
store.dispatch(setLogout());
|
|
93
|
+
return Promise.reject(error);
|
|
50
94
|
}
|
|
95
|
+
|
|
96
|
+
if (error.response?.status === 402) {
|
|
97
|
+
store.dispatch(setLogout());
|
|
98
|
+
}
|
|
99
|
+
|
|
51
100
|
return Promise.reject(error);
|
|
52
101
|
},
|
|
53
102
|
);
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import messaging, {
|
|
2
|
+
FirebaseMessagingTypes,
|
|
3
|
+
} from '@react-native-firebase/messaging';
|
|
4
|
+
import {AppState, AppStateStatus, Platform} from 'react-native';
|
|
5
|
+
import Snackbar from 'react-native-snackbar';
|
|
6
|
+
import {store} from '../store/store';
|
|
7
|
+
import {updateFcmToken} from '../store/user/userSlice';
|
|
8
|
+
import {isUserLoggedIn} from './notificationAuth';
|
|
9
|
+
import {routeFromNotificationData} from './routeFromNotificationData';
|
|
10
|
+
|
|
11
|
+
type Data = FirebaseMessagingTypes.RemoteMessage['data'];
|
|
12
|
+
|
|
13
|
+
let pendingBackgroundNotificationData: Data | null = null;
|
|
14
|
+
let appStateSubscription: {remove: () => void} | null = null;
|
|
15
|
+
let unsubscribeStore: (() => void) | null = null;
|
|
16
|
+
|
|
17
|
+
export function queueNotificationRouteFromBackgroundData(data: Data) {
|
|
18
|
+
pendingBackgroundNotificationData = data ?? null;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function tryFlushPending() {
|
|
22
|
+
if (!pendingBackgroundNotificationData) return;
|
|
23
|
+
if (!isUserLoggedIn()) return;
|
|
24
|
+
const data = pendingBackgroundNotificationData;
|
|
25
|
+
if (routeFromNotificationData(data as Record<string, string>)) {
|
|
26
|
+
pendingBackgroundNotificationData = null;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function requestPermissionIfNeeded() {
|
|
31
|
+
try {
|
|
32
|
+
if (Platform.OS === 'ios') {
|
|
33
|
+
await messaging().requestPermission();
|
|
34
|
+
}
|
|
35
|
+
} catch (e) {
|
|
36
|
+
if (__DEV__) console.warn('[push] permission request failed', e);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async function syncToken() {
|
|
41
|
+
try {
|
|
42
|
+
const token = await messaging().getToken();
|
|
43
|
+
if (token) {
|
|
44
|
+
store.dispatch(updateFcmToken(token));
|
|
45
|
+
}
|
|
46
|
+
} catch (e) {
|
|
47
|
+
if (__DEV__) console.warn('[push] getToken failed', e);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Register all push-notification listeners.
|
|
53
|
+
* Returns an unsubscribe function — call it on app unmount.
|
|
54
|
+
*
|
|
55
|
+
* Listeners covered:
|
|
56
|
+
* - cold-start tap (getInitialNotification)
|
|
57
|
+
* - foreground message (onMessage)
|
|
58
|
+
* - background tap (onNotificationOpenedApp)
|
|
59
|
+
* - token refresh (onTokenRefresh)
|
|
60
|
+
* - AppState resume + auth-state change → flush pending route
|
|
61
|
+
*/
|
|
62
|
+
export function startPushNotificationListeners() {
|
|
63
|
+
requestPermissionIfNeeded();
|
|
64
|
+
syncToken();
|
|
65
|
+
|
|
66
|
+
const unsubOnMessage = messaging().onMessage(remoteMessage => {
|
|
67
|
+
const title =
|
|
68
|
+
remoteMessage.notification?.title ?? remoteMessage.data?.title;
|
|
69
|
+
const body =
|
|
70
|
+
remoteMessage.notification?.body ?? remoteMessage.data?.body;
|
|
71
|
+
const text = [title, body].filter(Boolean).join(' — ');
|
|
72
|
+
if (text) {
|
|
73
|
+
Snackbar.show({text, duration: Snackbar.LENGTH_SHORT});
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
const unsubOnOpenedApp = messaging().onNotificationOpenedApp(remoteMessage => {
|
|
78
|
+
if (!remoteMessage?.data) return;
|
|
79
|
+
if (isUserLoggedIn()) {
|
|
80
|
+
routeFromNotificationData(remoteMessage.data as Record<string, string>);
|
|
81
|
+
} else {
|
|
82
|
+
pendingBackgroundNotificationData = remoteMessage.data;
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
messaging()
|
|
87
|
+
.getInitialNotification()
|
|
88
|
+
.then(remoteMessage => {
|
|
89
|
+
if (!remoteMessage?.data) return;
|
|
90
|
+
pendingBackgroundNotificationData = remoteMessage.data;
|
|
91
|
+
tryFlushPending();
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
const unsubOnTokenRefresh = messaging().onTokenRefresh(token => {
|
|
95
|
+
store.dispatch(updateFcmToken(token));
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
appStateSubscription = AppState.addEventListener(
|
|
99
|
+
'change',
|
|
100
|
+
(state: AppStateStatus) => {
|
|
101
|
+
if (state === 'active') tryFlushPending();
|
|
102
|
+
},
|
|
103
|
+
);
|
|
104
|
+
|
|
105
|
+
let lastAccessToken = store.getState().user?.accessToken;
|
|
106
|
+
unsubscribeStore = store.subscribe(() => {
|
|
107
|
+
const next = store.getState().user?.accessToken;
|
|
108
|
+
if (next && next !== lastAccessToken) {
|
|
109
|
+
lastAccessToken = next;
|
|
110
|
+
tryFlushPending();
|
|
111
|
+
} else {
|
|
112
|
+
lastAccessToken = next;
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
return () => {
|
|
117
|
+
unsubOnMessage();
|
|
118
|
+
unsubOnOpenedApp();
|
|
119
|
+
unsubOnTokenRefresh();
|
|
120
|
+
appStateSubscription?.remove();
|
|
121
|
+
appStateSubscription = null;
|
|
122
|
+
unsubscribeStore?.();
|
|
123
|
+
unsubscribeStore = null;
|
|
124
|
+
};
|
|
125
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import {navigate, navigationRef} from '../../navigation/RootNavigation';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Generic notification-tap router.
|
|
5
|
+
*
|
|
6
|
+
* FCM payloads carry routing hints in `data`. Convention:
|
|
7
|
+
* data.screen – screen name in any registered navigator
|
|
8
|
+
* data.params – JSON string of route params (optional)
|
|
9
|
+
*
|
|
10
|
+
* Extend per project: switch on `data.type` or `data.target` for
|
|
11
|
+
* deeper routing logic.
|
|
12
|
+
*/
|
|
13
|
+
export function routeFromNotificationData(
|
|
14
|
+
data: Record<string, string | undefined> | null | undefined,
|
|
15
|
+
) {
|
|
16
|
+
if (!data || !navigationRef.isReady()) return false;
|
|
17
|
+
|
|
18
|
+
const screen = data.screen;
|
|
19
|
+
if (!screen) return false;
|
|
20
|
+
|
|
21
|
+
let params: Record<string, unknown> | undefined;
|
|
22
|
+
if (data.params) {
|
|
23
|
+
try {
|
|
24
|
+
params = JSON.parse(data.params);
|
|
25
|
+
} catch {
|
|
26
|
+
params = undefined;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
navigate(screen as never, params as never);
|
|
31
|
+
return true;
|
|
32
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import {createAsyncThunk} from '@reduxjs/toolkit';
|
|
2
|
+
import {extractServerError} from '../../api/errorHandler';
|
|
3
|
+
import {handleFetchJsonResponse} from '../../api/responseHandlers';
|
|
4
|
+
import {get} from '../../api/serverHeaders';
|
|
5
|
+
import {ensureString} from '../../utils/stringUtils';
|
|
6
|
+
import {Category} from './categoriesState';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Example data thunk — GETs /categories and returns the list.
|
|
10
|
+
* Mirrors the user thunks: api helper + extractServerError + rejectWithValue.
|
|
11
|
+
*/
|
|
12
|
+
export const fetchCategories = createAsyncThunk<
|
|
13
|
+
Category[],
|
|
14
|
+
void,
|
|
15
|
+
{rejectValue: {message: string}}
|
|
16
|
+
>('categories/fetch', async (_arg, {rejectWithValue}) => {
|
|
17
|
+
try {
|
|
18
|
+
const response = await get({url: '/categories'});
|
|
19
|
+
const data = handleFetchJsonResponse(response);
|
|
20
|
+
return (data?.data ?? data ?? []) as Category[];
|
|
21
|
+
} catch (e: any) {
|
|
22
|
+
const serverError = extractServerError(e);
|
|
23
|
+
return rejectWithValue({message: ensureString(serverError.message)});
|
|
24
|
+
}
|
|
25
|
+
});
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import {PayloadAction, createSlice} from '@reduxjs/toolkit';
|
|
2
|
+
import {LoadState} from '../../../../types';
|
|
3
|
+
import {newState} from '../../../common/utils/newState';
|
|
4
|
+
import {fetchCategories} from './categoriesActions';
|
|
5
|
+
import {
|
|
6
|
+
Category,
|
|
7
|
+
CategoriesInitialState,
|
|
8
|
+
CategoriesState,
|
|
9
|
+
} from './categoriesState';
|
|
10
|
+
|
|
11
|
+
function pendingHandler(state: CategoriesState) {
|
|
12
|
+
return newState(state, {
|
|
13
|
+
loadState: state.categories.length
|
|
14
|
+
? LoadState.pullToRefresh
|
|
15
|
+
: LoadState.firstLoad,
|
|
16
|
+
error: null,
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function fulfilledHandler(
|
|
21
|
+
state: CategoriesState,
|
|
22
|
+
action: PayloadAction<Category[]>,
|
|
23
|
+
) {
|
|
24
|
+
return newState(state, {
|
|
25
|
+
categories: action.payload ?? [],
|
|
26
|
+
loadState: LoadState.allIsLoaded,
|
|
27
|
+
error: null,
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function rejectedHandler(
|
|
32
|
+
state: CategoriesState,
|
|
33
|
+
action: PayloadAction<{message: string} | undefined>,
|
|
34
|
+
) {
|
|
35
|
+
return newState(state, {
|
|
36
|
+
loadState: LoadState.error,
|
|
37
|
+
error: action.payload?.message ?? 'Failed to load categories',
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export const {reducer: CategoriesReducer} = createSlice({
|
|
42
|
+
name: 'categories',
|
|
43
|
+
initialState: CategoriesInitialState,
|
|
44
|
+
reducers: {},
|
|
45
|
+
extraReducers: builder => {
|
|
46
|
+
builder
|
|
47
|
+
.addCase(fetchCategories.pending, pendingHandler)
|
|
48
|
+
.addCase(fetchCategories.fulfilled, fulfilledHandler)
|
|
49
|
+
.addCase(fetchCategories.rejected, rejectedHandler);
|
|
50
|
+
},
|
|
51
|
+
});
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import {LoadState} from '../../../../types';
|
|
2
|
+
|
|
3
|
+
export interface Category {
|
|
4
|
+
id: string;
|
|
5
|
+
name: string;
|
|
6
|
+
icon?: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface CategoriesState {
|
|
10
|
+
categories: Category[];
|
|
11
|
+
loadState: LoadState;
|
|
12
|
+
error: string | null;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export const CategoriesInitialState: CategoriesState = {
|
|
16
|
+
categories: [],
|
|
17
|
+
loadState: LoadState.needLoad,
|
|
18
|
+
error: null,
|
|
19
|
+
};
|