@fadyshawky/react-native-magic 2.2.1 → 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.
Files changed (138) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +90 -56
  3. package/index.js +4 -0
  4. package/package.json +8 -2
  5. package/scripts/askPackageName.js +10 -5
  6. package/template/.env.development +8 -6
  7. package/template/.env.example +15 -5
  8. package/template/.env.production +8 -6
  9. package/template/.env.staging +8 -6
  10. package/template/.eslintrc.js +14 -0
  11. package/template/.husky/pre-commit +1 -0
  12. package/template/App.tsx +47 -16
  13. package/template/__tests__/App.test.tsx +28 -10
  14. package/template/babel.config.js +20 -1
  15. package/template/docs/ARCHITECTURE.md +40 -10
  16. package/template/docs/BEST_PRACTICES.md +10 -1
  17. package/template/docs/CUSTOMIZATION.md +118 -5
  18. package/template/docs/design-system.html +1164 -0
  19. package/template/docs/wireframes.html +411 -0
  20. package/template/index.js +10 -0
  21. package/template/jest.config.js +16 -1
  22. package/template/jest.setup.js +61 -0
  23. package/template/package-lock.json +12178 -8293
  24. package/template/package.json +53 -20
  25. package/template/react-native.config.js +3 -0
  26. package/template/resources/fonts/.gitkeep +0 -0
  27. package/template/scripts/ci-sync-env.cjs +71 -0
  28. package/template/src/assets/brand/logo-mark.svg +8 -0
  29. package/template/src/assets/brand/logo-mono.svg +9 -0
  30. package/template/src/assets/brand/logo-primary.svg +15 -0
  31. package/template/src/assets/brand/wordmark-dark.svg +18 -0
  32. package/template/src/common/components/AppBottomSheet.tsx +87 -0
  33. package/template/src/common/components/AppSwitch.tsx +75 -0
  34. package/template/src/common/components/AppTextInput.tsx +161 -0
  35. package/template/src/common/components/Avatar.tsx +75 -0
  36. package/template/src/common/components/Badge.tsx +66 -0
  37. package/template/src/common/components/CardScroller.tsx +58 -0
  38. package/template/src/common/components/Cards.tsx +13 -7
  39. package/template/src/common/components/Carousel.tsx +196 -0
  40. package/template/src/common/components/Checkbox.tsx +85 -0
  41. package/template/src/common/components/Chip.tsx +55 -0
  42. package/template/src/common/components/Dropdown.tsx +202 -0
  43. package/template/src/common/components/ErrorBoundary.tsx +82 -0
  44. package/template/src/common/components/FlatListWrapper.tsx +4 -5
  45. package/template/src/common/components/ListItem.tsx +90 -0
  46. package/template/src/common/components/LoadingComponent.tsx +8 -2
  47. package/template/src/common/components/Logo.tsx +77 -0
  48. package/template/src/common/components/ModalDialog.tsx +141 -0
  49. package/template/src/common/components/NetworkBanner.tsx +47 -0
  50. package/template/src/common/components/OTPInput.tsx +0 -1
  51. package/template/src/common/components/PrimaryButton.tsx +0 -14
  52. package/template/src/common/components/PrimaryTextInput.tsx +66 -130
  53. package/template/src/common/components/RadioGroup.tsx +95 -0
  54. package/template/src/common/components/SafeText.tsx +4 -3
  55. package/template/src/common/components/SearchBar.tsx +7 -5
  56. package/template/src/common/components/SegmentedControl.tsx +77 -0
  57. package/template/src/common/components/Skeleton.tsx +47 -0
  58. package/template/src/common/components/TryAgain.tsx +4 -2
  59. package/template/src/common/helpers/arrayHelpers.ts +2 -2
  60. package/template/src/common/helpers/defaultKeyIdExtractor.ts +1 -1
  61. package/template/src/common/helpers/regexHelpers.ts +1 -2
  62. package/template/src/common/helpers/stringsHelpers.ts +0 -1
  63. package/template/src/common/hooks/useBackHandler.ts +5 -2
  64. package/template/src/common/hooks/useEventRegister.ts +1 -1
  65. package/template/src/common/hooks/useFlatListActions.ts +1 -1
  66. package/template/src/common/hooks/useWhyDidYouUpdate.ts +1 -1
  67. package/template/src/common/localization/LocalizationProvider.tsx +1 -1
  68. package/template/src/common/localization/RTLInitializer.tsx +1 -1
  69. package/template/src/common/localization/dateFormatter.ts +0 -1
  70. package/template/src/common/localization/intlFormatter.ts +0 -1
  71. package/template/src/common/localization/localization.ts +2 -2
  72. package/template/src/common/localization/translations/homeLocalization.ts +14 -0
  73. package/template/src/common/localization/translations/loginLocalization.ts +8 -0
  74. package/template/src/common/localization/translations/mainNavigationLocalization.ts +2 -0
  75. package/template/src/common/localization/translations/profileLocalization.ts +16 -0
  76. package/template/src/common/utils/index.tsx +0 -6
  77. package/template/src/common/validations/commonValidations.ts +2 -2
  78. package/template/src/core/api/errorHandler.ts +1 -1
  79. package/template/src/core/api/responseHandlers.ts +1 -3
  80. package/template/src/core/api/serverHeaders.ts +61 -12
  81. package/template/src/core/notifications/notificationAuth.ts +6 -0
  82. package/template/src/core/notifications/notificationService.ts +125 -0
  83. package/template/src/core/notifications/routeFromNotificationData.ts +32 -0
  84. package/template/src/core/store/categories/categoriesActions.ts +25 -0
  85. package/template/src/core/store/categories/categoriesSlice.ts +51 -0
  86. package/template/src/core/store/categories/categoriesState.ts +19 -0
  87. package/template/src/core/store/rootReducer.ts +2 -0
  88. package/template/src/core/store/store.tsx +6 -1
  89. package/template/src/core/store/user/userActions.ts +75 -14
  90. package/template/src/core/store/user/userSlice.ts +49 -26
  91. package/template/src/core/store/user/userState.ts +6 -4
  92. package/template/src/core/theme/ThemeProvider.tsx +5 -3
  93. package/template/src/core/theme/brand.ts +50 -0
  94. package/template/src/core/theme/colors.ts +113 -99
  95. package/template/src/core/theme/commonConsts.ts +2 -2
  96. package/template/src/core/theme/commonStyles.ts +1 -1
  97. package/template/src/core/theme/themes.ts +2 -0
  98. package/template/src/core/theme/types.ts +4 -2
  99. package/template/src/core/utils/stringUtils.ts +1 -1
  100. package/template/src/design-system/index.ts +2 -0
  101. package/template/src/design-system/tokens/brand.ts +6 -0
  102. package/template/src/design-system/tokens/index.ts +3 -0
  103. package/template/src/design-system/tokens/palette.ts +4 -0
  104. package/template/src/design-system/tokens/typography-spacing.ts +2 -0
  105. package/template/src/navigation/AuthStack.tsx +1 -4
  106. package/template/src/navigation/HeaderComponents.tsx +6 -3
  107. package/template/src/navigation/MainStack.tsx +18 -6
  108. package/template/src/navigation/RootNavigation.tsx +4 -7
  109. package/template/src/navigation/TabBar.tsx +7 -6
  110. package/template/src/navigation/types.ts +10 -31
  111. package/template/src/screens/Login/Login.tsx +47 -47
  112. package/template/src/screens/OTP/OTPScreen.tsx +6 -9
  113. package/template/src/screens/components/ComponentsScreen.tsx +301 -0
  114. package/template/src/screens/home/HomeScreen.tsx +143 -1
  115. package/template/src/screens/home/hooks/useHomeData.ts +19 -5
  116. package/template/src/screens/index.tsx +1 -0
  117. package/template/src/screens/profile/Profile.tsx +139 -2
  118. package/template/src/screens/splash/Splash.tsx +44 -11
  119. package/template/src/sheetManager/sheets.tsx +1 -1
  120. package/template/tsconfig.json +14 -2
  121. package/template/types/globals.d.ts +43 -0
  122. package/template/types/index.ts +2 -6
  123. package/template/types/modules.d.ts +9 -0
  124. package/template/types/react-native-config.d.ts +0 -2
  125. package/.vscode/settings.json +0 -8
  126. package/CHANGELOG.md +0 -119
  127. package/CODE_OF_CONDUCT.md +0 -83
  128. package/CONTRIBUTING.md +0 -60
  129. package/local.properties +0 -1
  130. package/template/src/common/components/ImageCropPickerButton.tsx +0 -107
  131. package/template/src/common/components/PhotoTakingButton.tsx +0 -94
  132. package/template/src/common/helpers/imageHelpers.ts +0 -5
  133. package/template/src/common/helpers/inAppReviewHelper.ts +0 -30
  134. package/template/src/common/helpers/orientationHelpers.ts +0 -25
  135. package/template/src/common/helpers/shareHelpers.ts +0 -47
  136. package/template/src/common/utils/FeesCaalculation.tsx +0 -37
  137. package/template/src/common/utils/printData.tsx +0 -161
  138. package/template/src/common/validations/examples/TextInputWithValidation.tsx +0 -229
@@ -22,7 +22,7 @@ export function useEventRegister(
22
22
  return () => {
23
23
  listeners.set(
24
24
  eventName,
25
- callbacks.filter(i => i.id != id),
25
+ callbacks.filter(i => i.id !== id),
26
26
  );
27
27
  };
28
28
  }, [eventName, callback]);
@@ -16,7 +16,7 @@ export function useFlatListActions(
16
16
  const dispatch = useAppDispatch();
17
17
 
18
18
  const loadMore = useCallback(() => {
19
- loadState == LoadState.idle && dispatch(request(LoadState.loadingMore));
19
+ loadState === LoadState.idle && dispatch(request(LoadState.loadingMore));
20
20
  }, [dispatch, request, loadState]);
21
21
 
22
22
  const tryAgain = useCallback(() => {
@@ -1,7 +1,7 @@
1
1
  import {useEffect, useRef} from 'react';
2
2
 
3
3
  export function useWhyDidYouUpdate<T>(name: string, props: T): void {
4
- const previousProps = useRef<T>();
4
+ const previousProps = useRef<T | undefined>(undefined);
5
5
 
6
6
  useEffect(() => {
7
7
  if (previousProps.current) {
@@ -112,7 +112,7 @@ export const LocalizationProvider: React.FC<LocalizationProviderProps> = ({
112
112
  }
113
113
 
114
114
  return result[key] || key;
115
- } catch (error) {
115
+ } catch {
116
116
  console.warn(
117
117
  `Translation not found for key: ${key} in section: ${section}`,
118
118
  );
@@ -1,5 +1,5 @@
1
1
  import React, {useEffect, useState} from 'react';
2
- import {I18nManager, Platform, NativeModules} from 'react-native';
2
+ import {I18nManager} from 'react-native';
3
3
  import {useAppSelector} from '../../core/store/reduxHelpers';
4
4
  import {Languages} from './localization';
5
5
 
@@ -1,5 +1,4 @@
1
1
  import dayjs from 'dayjs';
2
- // eslint-disable-next-line import/no-unassigned-import
3
2
  import 'dayjs/locale/en';
4
3
  import calendar from 'dayjs/plugin/calendar';
5
4
  import {ICalendarSpec} from '../../../types';
@@ -1,6 +1,5 @@
1
1
  import Intl from 'intl';
2
2
  import {getLanguage} from './localization';
3
- // eslint-disable-next-line import/no-unassigned-import
4
3
  import 'intl/locale-data/jsonp/en';
5
4
 
6
5
  export function formatPercent(percent: number | string): string {
@@ -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.ar;
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 == toTime) {
19
+ if (fromTime === toTime) {
20
20
  return localization.errors.datesCantBeEqual(fromLabel, toLabel);
21
21
  } else {
22
22
  const isFromTimeLater = fromTime > toTime;
@@ -30,7 +30,7 @@ export const extractServerError = (error: any) => {
30
30
  } else if (error?.message) {
31
31
  errorObj.message = ensureString(error.message);
32
32
  }
33
- } catch (e) {}
33
+ } catch {}
34
34
 
35
35
  // Final safety check
36
36
  errorObj.message = ensureString(errorObj.message);
@@ -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
- showSuccessMessage?: boolean,
6
+ _showSuccessMessage?: boolean,
9
7
  ) => {
10
8
  return response.data;
11
9
  };
@@ -1,9 +1,15 @@
1
- import axios, {AxiosDefaults, AxiosError, AxiosResponse} from '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: HeadersInit = {
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
- headers: {
20
- ...defaultHeaders,
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
- if (
43
- originalRequest?.url?.includes('/login') ||
44
- originalRequest?.url?.includes('/refresh-token')
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
- if (error.response?.status === 401 || error.response?.status === 402) {
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,6 @@
1
+ import {store} from '../store/store';
2
+
3
+ export function isUserLoggedIn(): boolean {
4
+ const state = store.getState();
5
+ return !!state.user?.accessToken;
6
+ }
@@ -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
+ };
@@ -1,9 +1,11 @@
1
1
  import {combineReducers} from '@reduxjs/toolkit';
2
2
  import {AppReducer} from './app/appSlice';
3
+ import {CategoriesReducer} from './categories/categoriesSlice';
3
4
  import {UserReducer} from './user/userSlice';
4
5
  export const rootReducer = combineReducers({
5
6
  app: AppReducer,
6
7
  user: UserReducer,
8
+ categories: CategoriesReducer,
7
9
  });
8
10
 
9
11
  export type RootState = ReturnType<typeof rootReducer>;
@@ -13,7 +13,12 @@ const persistConfig: PersistConfig<RootState> = {
13
13
  version: 1,
14
14
  timeout: 1000,
15
15
  transforms: [
16
- createWhitelistFilter('user', ['accessToken', 'user']),
16
+ createWhitelistFilter('user', [
17
+ 'accessToken',
18
+ 'refreshToken',
19
+ 'fcmToken',
20
+ 'user',
21
+ ]),
17
22
  createWhitelistFilter('app', ['language', 'isRTL']),
18
23
  ],
19
24
  };