feat(app): add splash and apis
@ -1,6 +1,7 @@
|
||||
apply plugin: "com.android.application"
|
||||
apply plugin: "org.jetbrains.kotlin.android"
|
||||
apply plugin: "com.facebook.react"
|
||||
apply plugin: 'com.google.gms.google-services'
|
||||
|
||||
apply from: project(':react-native-config').projectDir.getPath() + "/dotenv.gradle"
|
||||
apply from: "../../node_modules/react-native-vector-icons/fonts.gradle"
|
||||
@ -81,14 +82,14 @@ android {
|
||||
buildToolsVersion rootProject.ext.buildToolsVersion
|
||||
compileSdk rootProject.ext.compileSdkVersion
|
||||
|
||||
namespace "com.convex_crm"
|
||||
namespace "app.convexsol.convexsolcrm"
|
||||
defaultConfig {
|
||||
applicationId "com.convex_crm"
|
||||
applicationId "app.convexsol.convexsolcrm"
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 1
|
||||
versionName "1.0"
|
||||
resValue "string", "build_config_package", "com.convex_crm"
|
||||
resValue "string", "build_config_package", "app.convexsol.convexsolcrm"
|
||||
}
|
||||
signingConfigs {
|
||||
debug {
|
||||
|
||||
29
android/app/google-services.json
Normal file
@ -0,0 +1,29 @@
|
||||
{
|
||||
"project_info": {
|
||||
"project_number": "712641953559",
|
||||
"project_id": "convex-crm-cdb42",
|
||||
"storage_bucket": "convex-crm-cdb42.firebasestorage.app"
|
||||
},
|
||||
"client": [
|
||||
{
|
||||
"client_info": {
|
||||
"mobilesdk_app_id": "1:712641953559:android:85a912a03f43148f5d0152",
|
||||
"android_client_info": {
|
||||
"package_name": "app.convexsol.convexsolcrm"
|
||||
}
|
||||
},
|
||||
"oauth_client": [],
|
||||
"api_key": [
|
||||
{
|
||||
"current_key": "AIzaSyBFeKwZ8CBf3x62U6qHTv-T_ufp4qTHDrA"
|
||||
}
|
||||
],
|
||||
"services": {
|
||||
"appinvite_service": {
|
||||
"other_platform_oauth_client": []
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"configuration_version": "1"
|
||||
}
|
||||
@ -1,6 +1,6 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
|
||||
<application
|
||||
android:name=".MainApplication"
|
||||
@ -17,7 +17,8 @@
|
||||
android:configChanges="keyboard|keyboardHidden|orientation|screenLayout|screenSize|smallestScreenSize|uiMode"
|
||||
android:launchMode="singleTask"
|
||||
android:windowSoftInputMode="adjustResize"
|
||||
android:exported="true">
|
||||
android:exported="true"
|
||||
android:theme="@style/BootTheme">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
|
||||
@ -1,12 +1,19 @@
|
||||
package com.convex_crm
|
||||
package app.convexsol.convexsolcrm
|
||||
|
||||
import android.os.Bundle
|
||||
import com.facebook.react.ReactActivity
|
||||
import com.facebook.react.ReactActivityDelegate
|
||||
import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint.fabricEnabled
|
||||
import com.facebook.react.defaults.DefaultReactActivityDelegate
|
||||
import com.zoontek.rnbootsplash.RNBootSplash
|
||||
|
||||
class MainActivity : ReactActivity() {
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
RNBootSplash.init(this, R.style.BootTheme)
|
||||
super.onCreate(savedInstanceState)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the name of the main component registered from JavaScript. This is used to schedule
|
||||
* rendering of the component.
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
package com.convex_crm
|
||||
package app.convexsol.convexsolcrm
|
||||
|
||||
import android.app.Application
|
||||
import com.facebook.react.PackageList
|
||||
|
||||
BIN
android/app/src/main/res/drawable-hdpi/bootsplash_logo.png
Normal file
|
After Width: | Height: | Size: 5.0 KiB |
BIN
android/app/src/main/res/drawable-mdpi/bootsplash_logo.png
Normal file
|
After Width: | Height: | Size: 3.3 KiB |
BIN
android/app/src/main/res/drawable-xhdpi/bootsplash_logo.png
Normal file
|
After Width: | Height: | Size: 7.2 KiB |
BIN
android/app/src/main/res/drawable-xxhdpi/bootsplash_logo.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
android/app/src/main/res/drawable-xxxhdpi/bootsplash_logo.png
Normal file
|
After Width: | Height: | Size: 20 KiB |
74
android/app/src/main/res/drawable/ic_launcher_background.xml
Normal file
@ -0,0 +1,74 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector
|
||||
android:height="108dp"
|
||||
android:width="108dp"
|
||||
android:viewportHeight="108"
|
||||
android:viewportWidth="108"
|
||||
xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<path android:fillColor="#3DDC84"
|
||||
android:pathData="M0,0h108v108h-108z"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M9,0L9,108"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M19,0L19,108"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M29,0L29,108"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M39,0L39,108"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M49,0L49,108"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M59,0L59,108"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M69,0L69,108"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M79,0L79,108"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M89,0L89,108"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M99,0L99,108"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M0,9L108,9"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M0,19L108,19"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M0,29L108,29"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M0,39L108,39"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M0,49L108,49"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M0,59L108,59"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M0,69L108,69"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M0,79L108,79"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M0,89L108,89"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M0,99L108,99"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M19,29L89,29"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M19,39L89,39"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M19,49L89,49"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M19,59L89,59"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M19,69L89,69"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M19,79L89,79"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M29,19L29,89"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M39,19L39,89"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M49,19L49,89"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M59,19L59,89"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M69,19L69,89"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M79,19L79,89"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
</vector>
|
||||
|
Before Width: | Height: | Size: 3.0 KiB After Width: | Height: | Size: 3.6 KiB |
BIN
android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png
Normal file
|
After Width: | Height: | Size: 9.5 KiB |
|
Before Width: | Height: | Size: 4.9 KiB After Width: | Height: | Size: 4.8 KiB |
|
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 2.1 KiB |
BIN
android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png
Normal file
|
After Width: | Height: | Size: 5.7 KiB |
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 2.9 KiB |
|
Before Width: | Height: | Size: 4.5 KiB After Width: | Height: | Size: 5.0 KiB |
BIN
android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 6.9 KiB After Width: | Height: | Size: 6.8 KiB |
@ -0,0 +1,74 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector
|
||||
android:height="108dp"
|
||||
android:width="108dp"
|
||||
android:viewportHeight="108"
|
||||
android:viewportWidth="108"
|
||||
xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<path android:fillColor="#3DDC84"
|
||||
android:pathData="M0,0h108v108h-108z"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M9,0L9,108"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M19,0L19,108"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M29,0L29,108"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M39,0L39,108"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M49,0L49,108"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M59,0L59,108"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M69,0L69,108"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M79,0L79,108"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M89,0L89,108"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M99,0L99,108"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M0,9L108,9"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M0,19L108,19"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M0,29L108,29"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M0,39L108,39"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M0,49L108,49"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M0,59L108,59"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M0,69L108,69"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M0,79L108,79"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M0,89L108,89"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M0,99L108,99"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M19,29L89,29"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M19,39L89,39"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M19,49L89,49"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M19,59L89,59"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M19,69L89,69"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M19,79L89,79"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M29,19L29,89"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M39,19L39,89"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M49,19L49,89"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M59,19L59,89"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M69,19L69,89"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M79,19L79,89"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
</vector>
|
||||
|
Before Width: | Height: | Size: 6.3 KiB After Width: | Height: | Size: 8.1 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 9.0 KiB After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 16 KiB |
BIN
android/app/src/main/res/playstore-icon.png
Normal file
|
After Width: | Height: | Size: 54 KiB |
3
android/app/src/main/res/values/colors.xml
Normal file
@ -0,0 +1,3 @@
|
||||
<resources>
|
||||
<color name="bootsplash_background">#ffffff</color>
|
||||
</resources>
|
||||
@ -1,9 +1,12 @@
|
||||
<resources>
|
||||
|
||||
<!-- Base application theme. -->
|
||||
<style name="AppTheme" parent="Theme.AppCompat.DayNight.NoActionBar">
|
||||
<!-- Customize your theme here. -->
|
||||
<item name="android:editTextBackground">@drawable/rn_edit_text_material</item>
|
||||
</style>
|
||||
|
||||
<style name="BootTheme" parent="Theme.BootSplash">
|
||||
<item name="bootSplashBackground">@color/bootsplash_background</item>
|
||||
<item name="bootSplashLogo">@drawable/bootsplash_logo</item>
|
||||
<item name="postBootSplashTheme">@style/AppTheme</item>
|
||||
</style>
|
||||
</resources>
|
||||
|
||||
@ -15,6 +15,7 @@ buildscript {
|
||||
classpath("com.android.tools.build:gradle")
|
||||
classpath("com.facebook.react:react-native-gradle-plugin")
|
||||
classpath("org.jetbrains.kotlin:kotlin-gradle-plugin")
|
||||
classpath ("com.google.gms:google-services:4.3.15")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
54
app/App.tsx
@ -1,11 +1,13 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { StyleSheet, StatusBar } from 'react-native';
|
||||
import { StatusBar } from 'react-native';
|
||||
import { SafeAreaProvider } from 'react-native-safe-area-context';
|
||||
import { RootNavigator } from './navigation/rootNavigator';
|
||||
import { ThemeProvider, useTheme } from './theme';
|
||||
import { Provider } from 'react-redux';
|
||||
import { store, persistor } from '@store';
|
||||
import { store, persistor, useAppDispatch, useAppSelector, getStatusList, getSourceList, getCountryList } from '@store';
|
||||
import { PersistGate } from 'redux-persist/integration/react';
|
||||
import { NotificationService } from '@services';
|
||||
import BootSplash from 'react-native-bootsplash';
|
||||
|
||||
const ThemedStatusBar = () => {
|
||||
const { theme: colors, isDark } = useTheme();
|
||||
@ -23,13 +25,61 @@ const ThemedStatusBar = () => {
|
||||
);
|
||||
};
|
||||
|
||||
/** Dispatches startup data fetches once the Redux store is ready. */
|
||||
const AppInit = () => {
|
||||
const dispatch = useAppDispatch();
|
||||
const token = useAppSelector(state => state.auth.token);
|
||||
|
||||
useEffect(() => {
|
||||
if (token) {
|
||||
dispatch(getStatusList());
|
||||
dispatch(getSourceList());
|
||||
dispatch(getCountryList());
|
||||
}
|
||||
}, [dispatch, token]);
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const App = () => {
|
||||
useEffect(() => {
|
||||
let cleanup: (() => void) | undefined;
|
||||
|
||||
const initApp = async () => {
|
||||
try {
|
||||
const cleanupFn = await NotificationService.initialize({
|
||||
onNotificationOpen: data => {
|
||||
// TODO: Use data (e.g. { screen, id }) to deep-navigate once
|
||||
console.log('[App] Notification opened with data:', data);
|
||||
},
|
||||
onTokenRefreshed: token => {
|
||||
// TODO: Send the refreshed token to your backend so the server
|
||||
// always has the latest FCM token for this device.
|
||||
console.log('[App] FCM token refreshed — sync to server:', token);
|
||||
},
|
||||
});
|
||||
cleanup = cleanupFn;
|
||||
} catch (err) {
|
||||
console.error('[App] NotificationService.initialize error:', err);
|
||||
} finally {
|
||||
await BootSplash.hide({ fade: true });
|
||||
}
|
||||
};
|
||||
|
||||
initApp();
|
||||
|
||||
return () => {
|
||||
cleanup?.();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Provider store={store}>
|
||||
<PersistGate loading={null} persistor={persistor}>
|
||||
<SafeAreaProvider>
|
||||
<ThemeProvider>
|
||||
<ThemedStatusBar />
|
||||
<AppInit />
|
||||
<RootNavigator />
|
||||
</ThemeProvider>
|
||||
</SafeAreaProvider>
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
export * from './authApi';
|
||||
export * from './leadsApi';
|
||||
export * from './leadDetailsApi';
|
||||
export * from './listApi';
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { LeadItem } from '@interfaces';
|
||||
import { LeadItem, LeadCountItem } from '@interfaces';
|
||||
import { api } from '@utils';
|
||||
|
||||
export const getLeadsApi = async (
|
||||
@ -8,3 +8,10 @@ export const getLeadsApi = async (
|
||||
const url = `/api/leads/${leadId}/${staffId}`;
|
||||
return await api.get<LeadItem[]>(url);
|
||||
};
|
||||
|
||||
export const getLeadCountListApi = async (
|
||||
staffId: string,
|
||||
): Promise<LeadCountItem[]> => {
|
||||
const url = `/api/leadcountlist/${staffId}`;
|
||||
return await api.get<LeadCountItem[]>(url);
|
||||
};
|
||||
|
||||
17
app/api/listApi.ts
Normal file
@ -0,0 +1,17 @@
|
||||
import { StatusListItem, SourceListItem, CountryListItem } from '@interfaces';
|
||||
import { api } from '@utils';
|
||||
|
||||
export const getStatusListApi = async (): Promise<StatusListItem[]> => {
|
||||
const url = 'api/statuslist';
|
||||
return await api.get<StatusListItem[]>(url);
|
||||
};
|
||||
|
||||
export const getSourceListApi = async (): Promise<SourceListItem[]> => {
|
||||
const url = 'api/sourcelist';
|
||||
return await api.get<SourceListItem[]>(url);
|
||||
};
|
||||
|
||||
export const getCountryListApi = async (): Promise<CountryListItem[]> => {
|
||||
const url = 'api/countrylist';
|
||||
return await api.get<CountryListItem[]>(url);
|
||||
};
|
||||
@ -5,20 +5,22 @@ export const getStyles = (colors: ThemeColors) =>
|
||||
StyleSheet.create({
|
||||
card: {
|
||||
backgroundColor: colors.card,
|
||||
borderRadius: 12,
|
||||
borderRadius: 18,
|
||||
padding: 16,
|
||||
marginBottom: 12,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.05,
|
||||
shadowRadius: 4,
|
||||
elevation: 2,
|
||||
marginBottom: 14,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
shadowColor: '#5B4CF5',
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.06,
|
||||
shadowRadius: 12,
|
||||
elevation: 3,
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: 12,
|
||||
marginBottom: 14,
|
||||
},
|
||||
leftSection: {
|
||||
flexDirection: 'row',
|
||||
@ -26,19 +28,21 @@ export const getStyles = (colors: ThemeColors) =>
|
||||
flex: 1,
|
||||
},
|
||||
iconContainer: {
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 20,
|
||||
width: 42,
|
||||
height: 42,
|
||||
borderRadius: 12,
|
||||
backgroundColor: colors.surface,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginRight: 12,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
},
|
||||
titleSection: {
|
||||
flex: 1,
|
||||
},
|
||||
title: {
|
||||
fontSize: 16,
|
||||
fontSize: 15,
|
||||
fontWeight: '700',
|
||||
color: colors.text,
|
||||
},
|
||||
@ -49,31 +53,31 @@ export const getStyles = (colors: ThemeColors) =>
|
||||
},
|
||||
actionsRow: {
|
||||
flexDirection: 'row',
|
||||
gap: 8,
|
||||
gap: 10,
|
||||
},
|
||||
actionButton: {
|
||||
actionBtn: {
|
||||
flex: 1,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 6,
|
||||
paddingVertical: 10,
|
||||
paddingHorizontal: 16,
|
||||
borderRadius: 8,
|
||||
paddingHorizontal: 14,
|
||||
borderRadius: 12,
|
||||
backgroundColor: colors.surface,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
},
|
||||
addButton: {
|
||||
addBtn: {
|
||||
backgroundColor: colors.icon,
|
||||
borderColor: colors.icon,
|
||||
},
|
||||
actionButtonText: {
|
||||
fontSize: 14,
|
||||
actionText: {
|
||||
fontSize: 13,
|
||||
fontWeight: '600',
|
||||
color: colors.text,
|
||||
marginLeft: 6,
|
||||
},
|
||||
addButtonText: {
|
||||
addText: {
|
||||
color: '#FFFFFF',
|
||||
},
|
||||
});
|
||||
|
||||
@ -17,11 +17,10 @@ export const ActionCard: React.FC<ActionCardProps> = ({
|
||||
|
||||
return (
|
||||
<View style={styles.card}>
|
||||
{/* Header with Icon and Title */}
|
||||
<View style={styles.header}>
|
||||
<View style={styles.leftSection}>
|
||||
<View style={styles.iconContainer}>
|
||||
<Icon name={icon} size={22} color={colors.icon} />
|
||||
<Icon name={icon} size={20} color={colors.icon} />
|
||||
</View>
|
||||
<View style={styles.titleSection}>
|
||||
<Text style={styles.title}>{title}</Text>
|
||||
@ -34,24 +33,21 @@ export const ActionCard: React.FC<ActionCardProps> = ({
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<View style={styles.actionsRow}>
|
||||
<TouchableOpacity
|
||||
style={[styles.actionButton, styles.addButton]}
|
||||
style={[styles.actionBtn, styles.addBtn]}
|
||||
onPress={onAddPress}
|
||||
activeOpacity={0.7}>
|
||||
<Icon name="add-circle-outline" size={18} color="#FFFFFF" />
|
||||
<Text style={[styles.actionButtonText, styles.addButtonText]}>
|
||||
Add
|
||||
</Text>
|
||||
activeOpacity={0.75}>
|
||||
<Icon name="add-circle-outline" size={16} color="#FFFFFF" />
|
||||
<Text style={[styles.actionText, styles.addText]}>Add</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
style={styles.actionButton}
|
||||
style={styles.actionBtn}
|
||||
onPress={onViewAllPress}
|
||||
activeOpacity={0.7}>
|
||||
<Icon name="eye-outline" size={18} color={colors.icon} />
|
||||
<Text style={styles.actionButtonText}>View All</Text>
|
||||
activeOpacity={0.75}>
|
||||
<Icon name="eye-outline" size={16} color={colors.icon} />
|
||||
<Text style={styles.actionText}>View All</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
@ -1,10 +1,15 @@
|
||||
import { TextInputProps } from 'react-native';
|
||||
import { TextInputProps, StyleProp, ViewStyle, TextStyle } from 'react-native';
|
||||
|
||||
export interface FormInputProps extends Omit<TextInputProps, 'style'> {
|
||||
label: string;
|
||||
label?: string;
|
||||
value: string;
|
||||
onChangeText: (text: string) => void;
|
||||
placeholder?: string;
|
||||
required?: boolean;
|
||||
multiline?: boolean;
|
||||
leftIcon?: React.ReactNode;
|
||||
rightIcon?: React.ReactNode;
|
||||
containerStyle?: StyleProp<ViewStyle>;
|
||||
inputContainerStyle?: StyleProp<ViewStyle>;
|
||||
inputStyle?: StyleProp<TextStyle>;
|
||||
}
|
||||
|
||||
@ -7,21 +7,38 @@ export const getStyles = (colors: ThemeColors) =>
|
||||
marginBottom: 16,
|
||||
},
|
||||
label: {
|
||||
fontSize: 14,
|
||||
fontSize: 13,
|
||||
fontWeight: '600',
|
||||
color: colors.text,
|
||||
color: colors.textSecondary,
|
||||
marginBottom: 8,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.5,
|
||||
},
|
||||
required: {
|
||||
color: '#EF4444',
|
||||
marginLeft: 2,
|
||||
},
|
||||
input: {
|
||||
inputWrapper: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: colors.surface,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
borderRadius: 10,
|
||||
paddingHorizontal: 16,
|
||||
borderRadius: 12,
|
||||
paddingHorizontal: 12,
|
||||
},
|
||||
leftIconWrapper: {
|
||||
marginRight: 8,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
rightIconWrapper: {
|
||||
marginLeft: 8,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
input: {
|
||||
flex: 1,
|
||||
paddingVertical: 12,
|
||||
fontSize: 15,
|
||||
color: colors.text,
|
||||
|
||||
@ -12,19 +12,28 @@ export const FormInput: React.FC<FormInputProps> = ({
|
||||
required = false,
|
||||
multiline = false,
|
||||
keyboardType = 'default',
|
||||
leftIcon,
|
||||
rightIcon,
|
||||
containerStyle,
|
||||
inputContainerStyle,
|
||||
inputStyle,
|
||||
...rest
|
||||
}) => {
|
||||
const { theme: colors } = useTheme();
|
||||
const styles = getStyles(colors);
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<View style={[styles.container, containerStyle]}>
|
||||
{label ? (
|
||||
<Text style={styles.label}>
|
||||
{label}
|
||||
{required && <Text style={styles.required}>*</Text>}
|
||||
</Text>
|
||||
) : null}
|
||||
<View style={[styles.inputWrapper, inputContainerStyle]}>
|
||||
{leftIcon ? <View style={styles.leftIconWrapper}>{leftIcon}</View> : null}
|
||||
<TextInput
|
||||
style={[styles.input, multiline && styles.multilineInput]}
|
||||
style={[styles.input, multiline && styles.multilineInput, inputStyle]}
|
||||
value={value}
|
||||
onChangeText={onChangeText}
|
||||
placeholder={placeholder}
|
||||
@ -34,6 +43,8 @@ export const FormInput: React.FC<FormInputProps> = ({
|
||||
keyboardType={keyboardType}
|
||||
{...rest}
|
||||
/>
|
||||
{rightIcon ? <View style={styles.rightIconWrapper}>{rightIcon}</View> : null}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
@ -8,3 +8,4 @@ export * from './formPicker';
|
||||
export * from './addItemButton';
|
||||
export * from './actionButton';
|
||||
export * from './checkboxWithLabel';
|
||||
export * from './statCard';
|
||||
|
||||
@ -3,77 +3,98 @@ import { ThemeColors } from '../../theme';
|
||||
|
||||
export const getStyles = (colors: ThemeColors) =>
|
||||
StyleSheet.create({
|
||||
container: {
|
||||
alignItems: 'center',
|
||||
paddingVertical: 24,
|
||||
paddingHorizontal: 16,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: colors.border,
|
||||
card: {
|
||||
backgroundColor: colors.card,
|
||||
},
|
||||
avatar: {
|
||||
width: 80,
|
||||
height: 80,
|
||||
borderRadius: 40,
|
||||
justifyContent: 'center',
|
||||
borderRadius: 20,
|
||||
padding: 20,
|
||||
alignItems: 'center',
|
||||
marginBottom: 16,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
shadowColor: '#5B4CF5',
|
||||
shadowOffset: { width: 0, height: 6 },
|
||||
shadowOpacity: 0.08,
|
||||
shadowRadius: 16,
|
||||
elevation: 4,
|
||||
},
|
||||
avatar: {
|
||||
width: 76,
|
||||
height: 76,
|
||||
borderRadius: 38,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginBottom: 14,
|
||||
},
|
||||
avatarText: {
|
||||
fontSize: 32,
|
||||
fontWeight: '700',
|
||||
fontSize: 26,
|
||||
fontWeight: '800',
|
||||
color: '#FFFFFF',
|
||||
letterSpacing: 0.5,
|
||||
},
|
||||
nameRow: {
|
||||
name: {
|
||||
fontSize: 20,
|
||||
fontWeight: '700',
|
||||
color: colors.text,
|
||||
textAlign: 'center',
|
||||
marginBottom: 4,
|
||||
},
|
||||
companyRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 6,
|
||||
marginBottom: 12,
|
||||
flexWrap: 'wrap',
|
||||
paddingHorizontal: 16,
|
||||
},
|
||||
name: {
|
||||
fontSize: 24,
|
||||
fontWeight: '700',
|
||||
color: colors.text,
|
||||
marginRight: 8,
|
||||
},
|
||||
statusBadge: {
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 6,
|
||||
borderRadius: 16,
|
||||
},
|
||||
statusText: {
|
||||
fontSize: 13,
|
||||
fontWeight: '600',
|
||||
textTransform: 'capitalize',
|
||||
},
|
||||
company: {
|
||||
fontSize: 16,
|
||||
fontSize: 13,
|
||||
color: colors.textSecondary,
|
||||
marginBottom: 12,
|
||||
textAlign: 'center',
|
||||
fontWeight: '500',
|
||||
},
|
||||
contactRow: {
|
||||
statusBadge: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginBottom: 8,
|
||||
paddingHorizontal: 16,
|
||||
gap: 6,
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 5,
|
||||
borderRadius: 20,
|
||||
marginBottom: 16,
|
||||
},
|
||||
contactLabel: {
|
||||
fontSize: 14,
|
||||
statusDot: {
|
||||
width: 6,
|
||||
height: 6,
|
||||
borderRadius: 3,
|
||||
},
|
||||
statusText: {
|
||||
fontSize: 11,
|
||||
fontWeight: '700',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.5,
|
||||
},
|
||||
quickActions: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 12,
|
||||
width: '100%',
|
||||
paddingTop: 14,
|
||||
borderTopWidth: StyleSheet.hairlineWidth,
|
||||
borderTopColor: colors.border,
|
||||
},
|
||||
actionBtn: {
|
||||
flex: 1,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 6,
|
||||
paddingVertical: 10,
|
||||
borderRadius: 12,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
backgroundColor: colors.surface,
|
||||
},
|
||||
actionBtnText: {
|
||||
fontSize: 12,
|
||||
fontWeight: '600',
|
||||
color: colors.text,
|
||||
marginRight: 8,
|
||||
minWidth: 50,
|
||||
},
|
||||
contactText: {
|
||||
fontSize: 14,
|
||||
color: colors.textSecondary,
|
||||
flex: 1,
|
||||
},
|
||||
iconButton: {
|
||||
marginLeft: 8,
|
||||
padding: 4,
|
||||
},
|
||||
});
|
||||
|
||||
@ -4,6 +4,7 @@ import Icon from 'react-native-vector-icons/Ionicons';
|
||||
import { useTheme } from '@theme';
|
||||
import { getStyles } from './leadDetailHeader.styles';
|
||||
import { LeadDetailHeaderProps } from './leadDetailHeader.props';
|
||||
import { getInitials, toRgba } from '@utils';
|
||||
|
||||
export const LeadDetailHeader: React.FC<LeadDetailHeaderProps> = ({
|
||||
name,
|
||||
@ -17,87 +18,62 @@ export const LeadDetailHeader: React.FC<LeadDetailHeaderProps> = ({
|
||||
onEmailPress,
|
||||
onPhonePress,
|
||||
}) => {
|
||||
const { theme: colors } = useTheme();
|
||||
const { theme: colors, isDark } = useTheme();
|
||||
const styles = getStyles(colors);
|
||||
|
||||
// Get initials from name
|
||||
const getInitials = (name: string): string => {
|
||||
if (!name) return '?';
|
||||
const nameParts = name.trim().split(' ');
|
||||
if (nameParts.length >= 2) {
|
||||
return (nameParts[0][0] + nameParts[1][0]).toUpperCase();
|
||||
}
|
||||
return name.substring(0, 2).toUpperCase();
|
||||
};
|
||||
const accent = statusColor || avatarColor || '#5B4CF5';
|
||||
const badgeBg = toRgba(accent, isDark ? 0.20 : 0.12);
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
{/* Avatar */}
|
||||
<View
|
||||
style={[
|
||||
styles.avatar,
|
||||
{ backgroundColor: avatarColor || statusColor || '#1565C0' },
|
||||
]}>
|
||||
<View style={styles.card}>
|
||||
<View style={[styles.avatar, { backgroundColor: accent }]}>
|
||||
<Text style={styles.avatarText}>{getInitials(name)}</Text>
|
||||
</View>
|
||||
|
||||
{/* Name and Status Row */}
|
||||
<View style={styles.nameRow}>
|
||||
<Text style={styles.name}>{name || 'No Name'}</Text>
|
||||
<Text style={styles.name} numberOfLines={1}>
|
||||
{name || 'No Name'}
|
||||
</Text>
|
||||
|
||||
{company && (
|
||||
<View style={styles.companyRow}>
|
||||
<Icon name="business-outline" size={13} color={colors.textMuted} />
|
||||
<Text style={styles.company}>{company}</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<TouchableOpacity
|
||||
onPress={onStatusPress}
|
||||
activeOpacity={onStatusPress ? 0.7 : 1}
|
||||
disabled={!onStatusPress}>
|
||||
<View
|
||||
style={[
|
||||
styles.statusBadge,
|
||||
{ backgroundColor: `${statusColor || '#6B7280'}20` },
|
||||
]}>
|
||||
<Text
|
||||
style={[
|
||||
styles.statusText,
|
||||
{ color: statusColor || '#6B7280' },
|
||||
]}>
|
||||
<View style={[styles.statusBadge, { backgroundColor: badgeBg }]}>
|
||||
<View style={[styles.statusDot, { backgroundColor: accent }]} />
|
||||
<Text style={[styles.statusText, { color: accent }]}>
|
||||
{statusName || 'No Status'}
|
||||
</Text>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* Company */}
|
||||
{company && <Text style={styles.company}>{company}</Text>}
|
||||
|
||||
{/* Email Row */}
|
||||
<View style={styles.quickActions}>
|
||||
{email && (
|
||||
<View style={styles.contactRow}>
|
||||
<Text style={styles.contactLabel}>Email:</Text>
|
||||
<Text style={styles.contactText}>{email}</Text>
|
||||
{onEmailPress && (
|
||||
<TouchableOpacity
|
||||
style={styles.iconButton}
|
||||
style={styles.actionBtn}
|
||||
onPress={onEmailPress}
|
||||
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}>
|
||||
<Icon name="send" size={18} color={colors.icon} />
|
||||
activeOpacity={0.7}>
|
||||
<Icon name="mail-outline" size={16} color={colors.icon} />
|
||||
<Text style={styles.actionBtnText}>Email</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Phone Row */}
|
||||
{phonenumber && (
|
||||
<View style={styles.contactRow}>
|
||||
<Text style={styles.contactLabel}>Phone:</Text>
|
||||
<Text style={styles.contactText}>{phonenumber}</Text>
|
||||
{onPhonePress && (
|
||||
<TouchableOpacity
|
||||
style={styles.iconButton}
|
||||
style={styles.actionBtn}
|
||||
onPress={onPhonePress}
|
||||
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}>
|
||||
<Icon name="call" size={18} color={colors.icon} />
|
||||
activeOpacity={0.7}>
|
||||
<Icon name="call-outline" size={16} color={colors.icon} />
|
||||
<Text style={styles.actionBtnText}>Call</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
@ -3,72 +3,174 @@ import { ThemeColors } from '../../theme';
|
||||
|
||||
export const getStyles = (colors: ThemeColors) =>
|
||||
StyleSheet.create({
|
||||
cardWrapper: {
|
||||
marginBottom: 16,
|
||||
borderRadius: 20,
|
||||
shadowColor: '#5B4CF5',
|
||||
shadowOffset: { width: 0, height: 6 },
|
||||
shadowOpacity: 0.10,
|
||||
shadowRadius: 18,
|
||||
elevation: 6,
|
||||
},
|
||||
card: {
|
||||
backgroundColor: colors.card,
|
||||
borderRadius: 12,
|
||||
padding: 16,
|
||||
marginBottom: 12,
|
||||
borderRadius: 20,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.05,
|
||||
shadowRadius: 4,
|
||||
elevation: 2,
|
||||
overflow: 'hidden',
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
},
|
||||
avatarContainer: {
|
||||
width: 56,
|
||||
height: 56,
|
||||
borderRadius: 28,
|
||||
leftStrip: {
|
||||
width: 4,
|
||||
borderTopLeftRadius: 20,
|
||||
borderBottomLeftRadius: 20,
|
||||
},
|
||||
cardInner: {
|
||||
flex: 1,
|
||||
padding: 15,
|
||||
paddingLeft: 14,
|
||||
},
|
||||
topSection: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'flex-start',
|
||||
marginBottom: 12,
|
||||
},
|
||||
avatar: {
|
||||
width: 48,
|
||||
height: 48,
|
||||
borderRadius: 24,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginRight: 12,
|
||||
},
|
||||
avatarText: {
|
||||
fontSize: 20,
|
||||
fontWeight: '700',
|
||||
fontSize: 16,
|
||||
fontWeight: '800',
|
||||
color: '#FFFFFF',
|
||||
letterSpacing: 0.5,
|
||||
},
|
||||
contentContainer: {
|
||||
meta: {
|
||||
flex: 1,
|
||||
},
|
||||
headerRow: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: 8,
|
||||
minWidth: 0,
|
||||
},
|
||||
name: {
|
||||
fontSize: 16,
|
||||
fontSize: 15,
|
||||
fontWeight: '700',
|
||||
color: colors.text,
|
||||
flex: 1,
|
||||
marginRight: 8,
|
||||
marginBottom: 3,
|
||||
letterSpacing: 0.05,
|
||||
},
|
||||
companyRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginBottom: 8,
|
||||
gap: 5,
|
||||
flexWrap: 'wrap',
|
||||
},
|
||||
companyText: {
|
||||
fontSize: 12,
|
||||
color: colors.textSecondary,
|
||||
fontWeight: '500',
|
||||
flexShrink: 1,
|
||||
},
|
||||
titleText: {
|
||||
fontSize: 11,
|
||||
color: colors.textMuted,
|
||||
flexShrink: 1,
|
||||
},
|
||||
dot: {
|
||||
width: 3,
|
||||
height: 3,
|
||||
borderRadius: 2,
|
||||
backgroundColor: colors.textMuted,
|
||||
opacity: 0.6,
|
||||
},
|
||||
statusBadge: {
|
||||
alignSelf: 'flex-start',
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 5,
|
||||
paddingHorizontal: 10,
|
||||
paddingVertical: 4,
|
||||
borderRadius: 12,
|
||||
flexShrink: 0,
|
||||
borderRadius: 20,
|
||||
},
|
||||
statusDot: {
|
||||
width: 5,
|
||||
height: 5,
|
||||
borderRadius: 3,
|
||||
},
|
||||
statusText: {
|
||||
fontSize: 11,
|
||||
fontWeight: '600',
|
||||
textTransform: 'capitalize',
|
||||
fontSize: 10,
|
||||
fontWeight: '700',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.7,
|
||||
},
|
||||
separator: {
|
||||
height: StyleSheet.hairlineWidth,
|
||||
backgroundColor: colors.border,
|
||||
marginVertical: 11,
|
||||
},
|
||||
contacts: {
|
||||
gap: 8,
|
||||
},
|
||||
contactRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: 6,
|
||||
gap: 9,
|
||||
},
|
||||
iconWrap: {
|
||||
width: 28,
|
||||
height: 28,
|
||||
borderRadius: 9,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
contactText: {
|
||||
fontSize: 13,
|
||||
fontSize: 12,
|
||||
color: colors.textSecondary,
|
||||
flex: 1,
|
||||
marginRight: 8,
|
||||
},
|
||||
contactIconButton: {
|
||||
padding: 4,
|
||||
actionBtn: {
|
||||
width: 28,
|
||||
height: 28,
|
||||
borderRadius: 9,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
footer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
marginTop: 12,
|
||||
paddingTop: 10,
|
||||
borderTopWidth: StyleSheet.hairlineWidth,
|
||||
borderTopColor: colors.border,
|
||||
},
|
||||
sourcePill: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: colors.surface,
|
||||
borderRadius: 20,
|
||||
paddingHorizontal: 9,
|
||||
paddingVertical: 4,
|
||||
gap: 5,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
},
|
||||
sourceText: {
|
||||
fontSize: 10,
|
||||
color: colors.textMuted,
|
||||
fontWeight: '600',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.5,
|
||||
},
|
||||
dateRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
},
|
||||
dateText: {
|
||||
fontSize: 10,
|
||||
color: colors.textMuted,
|
||||
},
|
||||
});
|
||||
|
||||
@ -1,83 +1,111 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
TouchableOpacity,
|
||||
} from 'react-native';
|
||||
import { View, Text, TouchableOpacity } from 'react-native';
|
||||
import Icon from 'react-native-vector-icons/Ionicons';
|
||||
import { useTheme } from '@theme';
|
||||
import { getStyles } from './leadItemCard.styles';
|
||||
import { LeadItemCardProps } from './leadItemCard.props';
|
||||
import { getInitials, handleEmailPress, handlePhonePress } from '@utils';
|
||||
import { getInitials, handleEmailPress, handlePhonePress, toRgba, formatDate } from '@utils';
|
||||
|
||||
export const LeadItemCard: React.FC<LeadItemCardProps> = ({
|
||||
item,
|
||||
onPress,
|
||||
}) => {
|
||||
const { theme: colors } = useTheme();
|
||||
export const LeadItemCard: React.FC<LeadItemCardProps> = ({ item, onPress }) => {
|
||||
const { theme: colors, isDark } = useTheme();
|
||||
const styles = getStyles(colors);
|
||||
|
||||
const accent = item.color?.startsWith('#') ? item.color : '#5B4CF5';
|
||||
const tint = (a: number) => toRgba(accent, isDark ? a + 0.08 : a);
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
style={styles.card}
|
||||
onPress={onPress}
|
||||
activeOpacity={0.7}>
|
||||
{/* Left side - Avatar/Initials */}
|
||||
<View style={[styles.avatarContainer, { backgroundColor: item.color || '#1565C0' }]}>
|
||||
<TouchableOpacity style={styles.cardWrapper} onPress={onPress} activeOpacity={0.8}>
|
||||
<View style={styles.card}>
|
||||
<View style={[styles.leftStrip, { backgroundColor: accent }]} />
|
||||
|
||||
<View style={styles.cardInner}>
|
||||
<View style={styles.topSection}>
|
||||
<View style={[styles.avatar, { backgroundColor: accent }]}>
|
||||
<Text style={styles.avatarText}>{getInitials(item.name)}</Text>
|
||||
</View>
|
||||
|
||||
{/* Right side - Content */}
|
||||
<View style={styles.contentContainer}>
|
||||
{/* Name and Status Row */}
|
||||
<View style={styles.headerRow}>
|
||||
<View style={styles.meta}>
|
||||
<Text style={styles.name} numberOfLines={1}>
|
||||
{item.name || 'No Name'}
|
||||
</Text>
|
||||
<View
|
||||
style={[
|
||||
styles.statusBadge,
|
||||
{ backgroundColor: `${item.color || '#6B7280'}20` },
|
||||
]}>
|
||||
<Text
|
||||
style={[
|
||||
styles.statusText,
|
||||
{ color: item.color || '#6B7280' },
|
||||
]}>
|
||||
|
||||
{(item.company || item.title) && (
|
||||
<View style={styles.companyRow}>
|
||||
{item.company && (
|
||||
<>
|
||||
<Icon name="business-outline" size={11} color={colors.textMuted} />
|
||||
<Text style={styles.companyText} numberOfLines={1}>{item.company}</Text>
|
||||
</>
|
||||
)}
|
||||
{item.company && item.title && <View style={styles.dot} />}
|
||||
{item.title && (
|
||||
<Text style={styles.titleText} numberOfLines={1}>{item.title}</Text>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View style={[styles.statusBadge, { backgroundColor: tint(0.10) }]}>
|
||||
<View style={[styles.statusDot, { backgroundColor: accent }]} />
|
||||
<Text style={[styles.statusText, { color: accent }]}>
|
||||
{item.status_name || item.status || 'No Status'}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Email Row */}
|
||||
{item.email ? (
|
||||
{(item.email || item.phonenumber) && (
|
||||
<>
|
||||
<View style={styles.separator} />
|
||||
<View style={styles.contacts}>
|
||||
{item.email && (
|
||||
<View style={styles.contactRow}>
|
||||
<Text style={styles.contactText} numberOfLines={1}>
|
||||
{item.email}
|
||||
</Text>
|
||||
<View style={[styles.iconWrap, { backgroundColor: tint(0.08) }]}>
|
||||
<Icon name="mail-outline" size={13} color={accent} />
|
||||
</View>
|
||||
<Text style={styles.contactText} numberOfLines={1}>{item.email}</Text>
|
||||
<TouchableOpacity
|
||||
style={styles.contactIconButton}
|
||||
style={[styles.actionBtn, { backgroundColor: tint(0.08) }]}
|
||||
onPress={() => handleEmailPress(item.email)}
|
||||
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}>
|
||||
<Icon name="mail" size={20} color={colors.icon} />
|
||||
hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}>
|
||||
<Icon name="send-outline" size={12} color={accent} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
) : null}
|
||||
)}
|
||||
|
||||
{/* Phone Row */}
|
||||
{item.phonenumber ? (
|
||||
{item.phonenumber && (
|
||||
<View style={styles.contactRow}>
|
||||
<Text style={styles.contactText} numberOfLines={1}>
|
||||
{item.phonenumber}
|
||||
</Text>
|
||||
<View style={[styles.iconWrap, { backgroundColor: tint(0.08) }]}>
|
||||
<Icon name="call-outline" size={13} color={accent} />
|
||||
</View>
|
||||
<Text style={styles.contactText} numberOfLines={1}>{item.phonenumber}</Text>
|
||||
<TouchableOpacity
|
||||
style={styles.contactIconButton}
|
||||
style={[styles.actionBtn, { backgroundColor: tint(0.08) }]}
|
||||
onPress={() => handlePhonePress(item.phonenumber)}
|
||||
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}>
|
||||
<Icon name="call" size={20} color={colors.icon} />
|
||||
hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}>
|
||||
<Icon name="call-outline" size={12} color={accent} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
) : null}
|
||||
)}
|
||||
</View>
|
||||
</>
|
||||
)}
|
||||
|
||||
<View style={styles.footer}>
|
||||
{(item.source_name || item.source) ? (
|
||||
<View style={styles.sourcePill}>
|
||||
<Icon name="radio-outline" size={10} color={colors.textMuted} />
|
||||
<Text style={styles.sourceText}>{item.source_name || item.source}</Text>
|
||||
</View>
|
||||
) : <View />}
|
||||
|
||||
{item.dateadded && (
|
||||
<View style={styles.dateRow}>
|
||||
<Icon name="time-outline" size={10} color={colors.textMuted} />
|
||||
<Text style={styles.dateText}>{formatDate(item.dateadded)}</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
|
||||
@ -9,18 +9,25 @@ export const getStyles = (colors: ThemeColors) =>
|
||||
backgroundColor: colors.surface,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
borderRadius: 12,
|
||||
paddingHorizontal: 12,
|
||||
borderRadius: 14,
|
||||
paddingHorizontal: 14,
|
||||
height: 48,
|
||||
// Branded shadow — subtle indigo glow
|
||||
shadowColor: '#5B4CF5',
|
||||
shadowOffset: { width: 0, height: 3 },
|
||||
shadowOpacity: 0.07,
|
||||
shadowRadius: 8,
|
||||
elevation: 2,
|
||||
},
|
||||
searchIcon: {
|
||||
marginRight: 8,
|
||||
marginRight: 9,
|
||||
},
|
||||
input: {
|
||||
flex: 1,
|
||||
fontSize: 15,
|
||||
fontSize: 14,
|
||||
color: colors.text,
|
||||
paddingVertical: 0,
|
||||
letterSpacing: 0.1,
|
||||
},
|
||||
clearButton: {
|
||||
marginLeft: 8,
|
||||
|
||||
2
app/components/statCard/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export * from './statCard';
|
||||
export * from './statCard.props';
|
||||
7
app/components/statCard/statCard.props.ts
Normal file
@ -0,0 +1,7 @@
|
||||
export interface StatCardProps {
|
||||
label: string;
|
||||
count: number;
|
||||
color: string;
|
||||
isSelected?: boolean;
|
||||
onPress?: () => void;
|
||||
}
|
||||
60
app/components/statCard/statCard.styles.ts
Normal file
@ -0,0 +1,60 @@
|
||||
import { StyleSheet } from 'react-native';
|
||||
import { ThemeColors } from '../../theme';
|
||||
|
||||
export const getStyles = (colors: ThemeColors) =>
|
||||
StyleSheet.create({
|
||||
card: {
|
||||
minWidth: 105,
|
||||
height: 84,
|
||||
borderRadius: 16,
|
||||
backgroundColor: colors.card,
|
||||
borderWidth: 1.5,
|
||||
borderColor: colors.border,
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 10,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
shadowColor: '#5B4CF5',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.05,
|
||||
shadowRadius: 6,
|
||||
elevation: 2,
|
||||
},
|
||||
cardSelected: {
|
||||
borderWidth: 2,
|
||||
shadowOpacity: 0.12,
|
||||
shadowRadius: 8,
|
||||
elevation: 3,
|
||||
},
|
||||
pillTag: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 9,
|
||||
paddingVertical: 4,
|
||||
borderRadius: 10,
|
||||
gap: 5,
|
||||
maxWidth: '100%',
|
||||
},
|
||||
pillTagInactive: {
|
||||
backgroundColor: colors.surface,
|
||||
},
|
||||
statusDot: {
|
||||
width: 6,
|
||||
height: 6,
|
||||
borderRadius: 3,
|
||||
},
|
||||
labelText: {
|
||||
fontSize: 11,
|
||||
fontWeight: '700',
|
||||
},
|
||||
countText: {
|
||||
fontSize: 24,
|
||||
fontWeight: '900',
|
||||
color: colors.text,
|
||||
textAlign: 'center',
|
||||
marginTop: 6,
|
||||
lineHeight: 28,
|
||||
includeFontPadding: false,
|
||||
letterSpacing: -0.5,
|
||||
},
|
||||
});
|
||||
67
app/components/statCard/statCard.tsx
Normal file
@ -0,0 +1,67 @@
|
||||
import React from 'react';
|
||||
import { View, Text, TouchableOpacity } from 'react-native';
|
||||
import { useTheme } from '../../theme';
|
||||
import { getStyles } from './statCard.styles';
|
||||
import { StatCardProps } from './statCard.props';
|
||||
|
||||
export const StatCard: React.FC<StatCardProps> = ({
|
||||
label,
|
||||
count,
|
||||
color,
|
||||
isSelected = false,
|
||||
onPress,
|
||||
}) => {
|
||||
const { theme: colors } = useTheme();
|
||||
const styles = getStyles(colors);
|
||||
|
||||
const themeColor = color || colors.icon;
|
||||
const borderColor = isSelected ? themeColor : colors.border;
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
activeOpacity={onPress ? 0.8 : 1}
|
||||
onPress={onPress}
|
||||
style={[
|
||||
styles.card,
|
||||
{ borderColor },
|
||||
isSelected && styles.cardSelected,
|
||||
]}
|
||||
>
|
||||
{/* Top Status Pill Tag */}
|
||||
<View
|
||||
style={[
|
||||
styles.pillTag,
|
||||
isSelected
|
||||
? { backgroundColor: themeColor }
|
||||
: styles.pillTagInactive,
|
||||
]}
|
||||
>
|
||||
<View
|
||||
style={[
|
||||
styles.statusDot,
|
||||
{ backgroundColor: isSelected ? '#FFFFFF' : themeColor },
|
||||
]}
|
||||
/>
|
||||
<Text
|
||||
style={[
|
||||
styles.labelText,
|
||||
{ color: isSelected ? '#FFFFFF' : colors.textSecondary },
|
||||
]}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{label}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* Large Metric Count */}
|
||||
<Text
|
||||
style={[
|
||||
styles.countText,
|
||||
{ color: isSelected ? themeColor : colors.text },
|
||||
]}
|
||||
>
|
||||
{count}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
@ -1,11 +1,6 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
ScrollView,
|
||||
Linking,
|
||||
Platform,
|
||||
} from 'react-native';
|
||||
import { View, Text, ScrollView, TouchableOpacity } from 'react-native';
|
||||
import Icon from 'react-native-vector-icons/Ionicons';
|
||||
import { RouteProp, useRoute, useNavigation } from '@react-navigation/native';
|
||||
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||
import { useTheme } from '@theme';
|
||||
@ -14,14 +9,10 @@ import { getLeadDetails } from './thunk';
|
||||
import { getStyles } from './leadDetails.styles';
|
||||
import { LeadsStackParamList } from '../../navigation/leadsStack';
|
||||
import { Loader, LeadDetailHeader, ActionCard } from '@components';
|
||||
import { handleEmailPress, handlePhonePress } from '@utils';
|
||||
import { route as routes } from '@utils';
|
||||
import { handleEmailPress, handlePhonePress, handleWebsitePress, formatDate, route as routes } from '@utils';
|
||||
|
||||
type LeadDetailsRouteProp = RouteProp<LeadsStackParamList, 'leadDetails'>;
|
||||
type LeadDetailsNavigationProp = NativeStackNavigationProp<
|
||||
LeadsStackParamList,
|
||||
'leadDetails'
|
||||
>;
|
||||
type LeadDetailsNavigationProp = NativeStackNavigationProp<LeadsStackParamList, 'leadDetails'>;
|
||||
|
||||
export const LeadDetailsScreen = () => {
|
||||
const route = useRoute<LeadDetailsRouteProp>();
|
||||
@ -30,35 +21,23 @@ export const LeadDetailsScreen = () => {
|
||||
const { theme: colors } = useTheme();
|
||||
const styles = getStyles(colors);
|
||||
|
||||
// Get lead ID from route params
|
||||
const leadId = route.params?.lead?.id;
|
||||
|
||||
// Get data from Redux store
|
||||
const { item: lead, loading, error } = useAppSelector(
|
||||
(state: RootState) => state.leadDetails,
|
||||
);
|
||||
const { item: lead, loading, error } = useAppSelector((state: RootState) => state.leadDetails);
|
||||
const user_data = useAppSelector((state: RootState) => state.auth.user_data);
|
||||
|
||||
// Fetch lead details on mount
|
||||
useEffect(() => {
|
||||
if (leadId && user_data?.staffid) {
|
||||
dispatch(getLeadDetails({ leadId, staffid: user_data.staffid }));
|
||||
}
|
||||
}, [dispatch, leadId, user_data]);
|
||||
|
||||
// Show loader while loading
|
||||
if (loading) {
|
||||
return <Loader message="Loading lead details..." />;
|
||||
}
|
||||
if (loading) return <Loader message="Loading lead details..." />;
|
||||
|
||||
// Show error if any
|
||||
if (error) {
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<View style={{ flex: 1, justifyContent: 'center', alignItems: 'center', padding: 32 }}>
|
||||
<Text style={{ color: '#EF4444', fontSize: 14, textAlign: 'center' }}>
|
||||
{error}
|
||||
</Text>
|
||||
<View style={styles.errorContainer}>
|
||||
<Text style={styles.errorText}>{error}</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
@ -67,50 +46,19 @@ export const LeadDetailsScreen = () => {
|
||||
if (!lead) {
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<View style={styles.errorContainer}>
|
||||
<Text style={styles.infoValue}>No lead data available</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// Handle status press (for future status change functionality)
|
||||
const handleStatusPress = () => {
|
||||
// TODO: Show status list modal/picker
|
||||
console.log('Status pressed - show status list');
|
||||
};
|
||||
|
||||
// Proposals handlers
|
||||
const handleAddProposal = () => {
|
||||
// TODO: Navigate to add proposal screen
|
||||
navigation.navigate(routes.leadProposals, { leadId: lead.id });
|
||||
};
|
||||
|
||||
const handleViewAllProposals = () => {
|
||||
console.log('view Proposal pressed');
|
||||
};
|
||||
|
||||
// Tasks handlers
|
||||
const handleAddTask = () => {
|
||||
// TODO: Navigate to add task screen
|
||||
navigation.navigate(routes.leadTasks, { leadId: lead.id });
|
||||
};
|
||||
|
||||
const handleViewAllTasks = () => {
|
||||
console.log('view Add Task pressed');
|
||||
};
|
||||
|
||||
// Notes handlers
|
||||
const handleAddNote = () => {
|
||||
// TODO: Navigate to add note screen
|
||||
navigation.navigate(routes.leadNotes, { leadId: lead.id });
|
||||
};
|
||||
|
||||
const handleViewAllNotes = () => {
|
||||
console.log('view Add Note pressed');
|
||||
};
|
||||
const addressString = [lead.address, lead.city, lead.state, lead.country]
|
||||
.filter(Boolean)
|
||||
.join(', ');
|
||||
|
||||
return (
|
||||
<ScrollView style={styles.container} contentContainerStyle={styles.scrollContent}>
|
||||
{/* Header with Avatar */}
|
||||
<LeadDetailHeader
|
||||
name={lead.name}
|
||||
company={lead.company}
|
||||
@ -118,35 +66,139 @@ export const LeadDetailsScreen = () => {
|
||||
phonenumber={lead.phonenumber}
|
||||
statusName={lead.status_name || lead.status}
|
||||
statusColor={lead.color}
|
||||
onStatusPress={handleStatusPress}
|
||||
onEmailPress={() => handleEmailPress(lead.email)}
|
||||
onPhonePress={() => handlePhonePress(lead.phonenumber)}
|
||||
/>
|
||||
|
||||
{/* Action Cards Section */}
|
||||
<View style={styles.statsRow}>
|
||||
<View style={styles.statCard}>
|
||||
<Text style={styles.statLabel}>Lead Value</Text>
|
||||
<Text style={styles.statValue}>
|
||||
{lead.lead_value ? `$${lead.lead_value}` : 'N/A'}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.statCard}>
|
||||
<Text style={styles.statLabel}>Source</Text>
|
||||
<Text style={styles.statValue} numberOfLines={1}>
|
||||
{lead.source_name || lead.source || 'Direct'}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.statCard}>
|
||||
<Text style={styles.statLabel}>Created</Text>
|
||||
<Text style={styles.statValue} numberOfLines={1}>
|
||||
{formatDate(lead.dateadded) || 'N/A'}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<Text style={styles.sectionTitle}>Lead Information</Text>
|
||||
<View style={styles.infoCard}>
|
||||
{lead.email ? (
|
||||
<>
|
||||
<TouchableOpacity
|
||||
style={styles.infoRow}
|
||||
onPress={() => handleEmailPress(lead.email)}
|
||||
activeOpacity={0.7}>
|
||||
<View style={styles.infoIconWrap}>
|
||||
<Icon name="mail-outline" size={15} color={colors.icon} />
|
||||
</View>
|
||||
<View style={styles.infoMeta}>
|
||||
<Text style={styles.infoLabel}>Email</Text>
|
||||
<Text style={styles.infoValue}>{lead.email}</Text>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
<View style={styles.divider} />
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{lead.phonenumber ? (
|
||||
<>
|
||||
<TouchableOpacity
|
||||
style={styles.infoRow}
|
||||
onPress={() => handlePhonePress(lead.phonenumber)}
|
||||
activeOpacity={0.7}>
|
||||
<View style={styles.infoIconWrap}>
|
||||
<Icon name="call-outline" size={15} color={colors.icon} />
|
||||
</View>
|
||||
<View style={styles.infoMeta}>
|
||||
<Text style={styles.infoLabel}>Phone</Text>
|
||||
<Text style={styles.infoValue}>{lead.phonenumber}</Text>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
<View style={styles.divider} />
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{lead.website ? (
|
||||
<>
|
||||
<TouchableOpacity
|
||||
style={styles.infoRow}
|
||||
onPress={() => handleWebsitePress(lead.website)}
|
||||
activeOpacity={0.7}>
|
||||
<View style={styles.infoIconWrap}>
|
||||
<Icon name="globe-outline" size={15} color={colors.icon} />
|
||||
</View>
|
||||
<View style={styles.infoMeta}>
|
||||
<Text style={styles.infoLabel}>Website</Text>
|
||||
<Text style={styles.infoValue}>{lead.website}</Text>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
<View style={styles.divider} />
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{addressString ? (
|
||||
<>
|
||||
<View style={styles.infoRow}>
|
||||
<View style={styles.infoIconWrap}>
|
||||
<Icon name="location-outline" size={15} color={colors.icon} />
|
||||
</View>
|
||||
<View style={styles.infoMeta}>
|
||||
<Text style={styles.infoLabel}>Address</Text>
|
||||
<Text style={styles.infoValue}>{addressString}</Text>
|
||||
</View>
|
||||
</View>
|
||||
<View style={styles.divider} />
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{lead.assigned ? (
|
||||
<View style={styles.infoRow}>
|
||||
<View style={styles.infoIconWrap}>
|
||||
<Icon name="person-outline" size={15} color={colors.icon} />
|
||||
</View>
|
||||
<View style={styles.infoMeta}>
|
||||
<Text style={styles.infoLabel}>Assigned Staff</Text>
|
||||
<Text style={styles.infoValue}>{lead.assigned}</Text>
|
||||
</View>
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
|
||||
<Text style={styles.sectionTitle}>Activity & Management</Text>
|
||||
<View style={styles.cardsContainer}>
|
||||
<ActionCard
|
||||
title="Proposals"
|
||||
icon="document-text-outline"
|
||||
count={0}
|
||||
onAddPress={handleAddProposal}
|
||||
onViewAllPress={handleViewAllProposals}
|
||||
onAddPress={() => navigation.navigate(routes.leadProposals, { leadId: lead.id })}
|
||||
onViewAllPress={() => navigation.navigate(routes.leadProposals, { leadId: lead.id })}
|
||||
/>
|
||||
|
||||
<ActionCard
|
||||
title="Tasks"
|
||||
icon="checkbox-outline"
|
||||
count={0}
|
||||
onAddPress={handleAddTask}
|
||||
onViewAllPress={handleViewAllTasks}
|
||||
onAddPress={() => navigation.navigate(routes.leadTasks, { leadId: lead.id })}
|
||||
onViewAllPress={() => navigation.navigate(routes.leadTasks, { leadId: lead.id })}
|
||||
/>
|
||||
|
||||
<ActionCard
|
||||
title="Notes"
|
||||
icon="create-outline"
|
||||
count={0}
|
||||
onAddPress={handleAddNote}
|
||||
onViewAllPress={handleViewAllNotes}
|
||||
onAddPress={() => navigation.navigate(routes.leadNotes, { leadId: lead.id })}
|
||||
onViewAllPress={() => navigation.navigate(routes.leadNotes, { leadId: lead.id })}
|
||||
/>
|
||||
</View>
|
||||
</ScrollView>
|
||||
|
||||
@ -9,114 +9,102 @@ export const getStyles = (colors: ThemeColors) =>
|
||||
},
|
||||
scrollContent: {
|
||||
padding: 16,
|
||||
},
|
||||
cardsContainer: {
|
||||
marginTop: 16,
|
||||
},
|
||||
header: {
|
||||
alignItems: 'center',
|
||||
paddingVertical: 24,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: colors.border,
|
||||
marginBottom: 16,
|
||||
},
|
||||
avatar: {
|
||||
width: 80,
|
||||
height: 80,
|
||||
borderRadius: 40,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginBottom: 12,
|
||||
},
|
||||
avatarText: {
|
||||
fontSize: 32,
|
||||
fontWeight: '700',
|
||||
color: '#FFFFFF',
|
||||
},
|
||||
name: {
|
||||
fontSize: 24,
|
||||
fontWeight: '700',
|
||||
color: colors.text,
|
||||
marginBottom: 4,
|
||||
},
|
||||
company: {
|
||||
fontSize: 16,
|
||||
color: colors.textSecondary,
|
||||
marginBottom: 8,
|
||||
},
|
||||
statusBadge: {
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 6,
|
||||
borderRadius: 16,
|
||||
},
|
||||
statusText: {
|
||||
fontSize: 13,
|
||||
fontWeight: '600',
|
||||
textTransform: 'capitalize',
|
||||
},
|
||||
section: {
|
||||
backgroundColor: colors.card,
|
||||
borderRadius: 12,
|
||||
padding: 16,
|
||||
marginBottom: 16,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.05,
|
||||
shadowRadius: 4,
|
||||
elevation: 2,
|
||||
paddingBottom: 32,
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: 16,
|
||||
fontSize: 15,
|
||||
fontWeight: '700',
|
||||
color: colors.text,
|
||||
marginBottom: 12,
|
||||
letterSpacing: 0.1,
|
||||
},
|
||||
statsRow: {
|
||||
flexDirection: 'row',
|
||||
gap: 10,
|
||||
marginBottom: 16,
|
||||
},
|
||||
statCard: {
|
||||
flex: 1,
|
||||
backgroundColor: colors.card,
|
||||
borderRadius: 16,
|
||||
padding: 14,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
shadowColor: '#5B4CF5',
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.05,
|
||||
shadowRadius: 10,
|
||||
elevation: 2,
|
||||
},
|
||||
statLabel: {
|
||||
fontSize: 11,
|
||||
color: colors.textMuted,
|
||||
marginBottom: 4,
|
||||
fontWeight: '500',
|
||||
},
|
||||
statValue: {
|
||||
fontSize: 15,
|
||||
fontWeight: '700',
|
||||
color: colors.text,
|
||||
},
|
||||
infoCard: {
|
||||
backgroundColor: colors.card,
|
||||
borderRadius: 18,
|
||||
padding: 16,
|
||||
marginBottom: 16,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
shadowColor: '#5B4CF5',
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.05,
|
||||
shadowRadius: 10,
|
||||
elevation: 2,
|
||||
},
|
||||
infoRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginBottom: 12,
|
||||
paddingVertical: 9,
|
||||
gap: 12,
|
||||
},
|
||||
infoIcon: {
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 20,
|
||||
infoIconWrap: {
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: 10,
|
||||
backgroundColor: colors.surface,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginRight: 12,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
},
|
||||
infoContent: {
|
||||
infoMeta: {
|
||||
flex: 1,
|
||||
},
|
||||
infoLabel: {
|
||||
fontSize: 12,
|
||||
fontSize: 11,
|
||||
color: colors.textMuted,
|
||||
marginBottom: 2,
|
||||
},
|
||||
infoValue: {
|
||||
fontSize: 14,
|
||||
color: colors.text,
|
||||
fontWeight: '500',
|
||||
},
|
||||
actionButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: colors.icon,
|
||||
paddingVertical: 14,
|
||||
paddingHorizontal: 20,
|
||||
borderRadius: 12,
|
||||
marginBottom: 12,
|
||||
},
|
||||
actionButtonText: {
|
||||
fontSize: 15,
|
||||
fontSize: 13,
|
||||
fontWeight: '600',
|
||||
color: '#FFFFFF',
|
||||
marginLeft: 8,
|
||||
color: colors.text,
|
||||
},
|
||||
noteText: {
|
||||
divider: {
|
||||
height: StyleSheet.hairlineWidth,
|
||||
backgroundColor: colors.border,
|
||||
},
|
||||
cardsContainer: {
|
||||
marginTop: 4,
|
||||
},
|
||||
errorContainer: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
padding: 32,
|
||||
},
|
||||
errorText: {
|
||||
color: '#EF4444',
|
||||
fontSize: 14,
|
||||
color: colors.textSecondary,
|
||||
lineHeight: 20,
|
||||
textAlign: 'center',
|
||||
},
|
||||
});
|
||||
|
||||
@ -3,7 +3,9 @@ import {
|
||||
Text,
|
||||
View,
|
||||
FlatList,
|
||||
ScrollView,
|
||||
TouchableOpacity,
|
||||
ActivityIndicator,
|
||||
} from 'react-native';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||
@ -11,9 +13,9 @@ import Icon from 'react-native-vector-icons/Ionicons';
|
||||
import { getStyles } from './leads.styles';
|
||||
import { useTheme } from '@theme';
|
||||
import { useAppDispatch, useAppSelector, RootState } from '@store';
|
||||
import { getLeads } from './thunk';
|
||||
import { getLeads, getLeadCountList } from './thunk';
|
||||
import { LeadItem } from '@interfaces';
|
||||
import { LeadItemCard, SearchInput, Loader } from '@components';
|
||||
import { LeadItemCard, SearchInput, Loader, StatCard } from '@components';
|
||||
import { LeadsStackParamList } from '../../navigation/leadsStack';
|
||||
import { route } from '@utils';
|
||||
|
||||
@ -28,8 +30,9 @@ export const LeadsScreen = () => {
|
||||
const { theme: colors } = useTheme();
|
||||
const styles = getStyles(colors);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [selectedStatusId, setSelectedStatusId] = useState<string | null>(null);
|
||||
|
||||
const { items, loading, error } = useAppSelector(
|
||||
const { items, loading, error, counts, countsLoading } = useAppSelector(
|
||||
(state: RootState) => state.leads,
|
||||
);
|
||||
const user_data = useAppSelector((state: RootState) => state.auth.user_data);
|
||||
@ -37,20 +40,30 @@ export const LeadsScreen = () => {
|
||||
useEffect(() => {
|
||||
if (user_data?.staffid) {
|
||||
dispatch(getLeads({ leadId: null, staffid: user_data.staffid }));
|
||||
dispatch(getLeadCountList(user_data.staffid));
|
||||
}
|
||||
}, [dispatch, user_data]);
|
||||
|
||||
const filteredLeads = searchTerm
|
||||
? items.filter(
|
||||
lead =>
|
||||
lead.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
const filteredLeads = items.filter(lead => {
|
||||
const matchesSearch = searchTerm
|
||||
? lead.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
lead.company.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
lead.email.toLowerCase().includes(searchTerm.toLowerCase()),
|
||||
)
|
||||
: items;
|
||||
lead.email.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
: true;
|
||||
|
||||
const matchesStatus =
|
||||
!selectedStatusId || selectedStatusId === '0'
|
||||
? true
|
||||
: lead.status === selectedStatusId;
|
||||
|
||||
return matchesSearch && matchesStatus;
|
||||
});
|
||||
|
||||
const handleStatusPress = (id: string) => {
|
||||
setSelectedStatusId(prev => (prev === id ? null : id));
|
||||
};
|
||||
|
||||
const handleLeadPress = (lead: LeadItem) => {
|
||||
// Navigate to lead details screen
|
||||
navigation.navigate(route.leadDetails, { lead });
|
||||
};
|
||||
|
||||
@ -60,6 +73,7 @@ export const LeadsScreen = () => {
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
{/* Search + Filter Header (Fixed at top) */}
|
||||
<View style={styles.header}>
|
||||
<SearchInput
|
||||
value={searchTerm}
|
||||
@ -72,6 +86,32 @@ export const LeadsScreen = () => {
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* Status count cards */}
|
||||
{countsLoading ? (
|
||||
<View style={styles.countsLoading}>
|
||||
<ActivityIndicator size="small" color={colors.icon} />
|
||||
</View>
|
||||
) : counts.length > 0 ? (
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
style={{ flexGrow: 0 }}
|
||||
contentContainerStyle={styles.countsScroll}
|
||||
>
|
||||
{counts.map(item => (
|
||||
<StatCard
|
||||
key={item.id}
|
||||
label={item.name}
|
||||
count={item.count}
|
||||
color={item.color || colors.icon}
|
||||
isSelected={selectedStatusId === item.id}
|
||||
onPress={() => handleStatusPress(item.id)}
|
||||
/>
|
||||
))}
|
||||
</ScrollView>
|
||||
) : null}
|
||||
|
||||
{/* Lead list */}
|
||||
{loading ? (
|
||||
<Loader message="Loading leads..." />
|
||||
) : error ? (
|
||||
@ -82,11 +122,20 @@ export const LeadsScreen = () => {
|
||||
<FlatList
|
||||
data={filteredLeads}
|
||||
keyExtractor={item => item.id}
|
||||
style={{ flex: 1 }}
|
||||
contentContainerStyle={styles.listContent}
|
||||
renderItem={renderLead}
|
||||
ListEmptyComponent={() => (
|
||||
<View style={styles.emptyContainer}>
|
||||
<Text style={styles.emptyText}>No leads available.</Text>
|
||||
<View style={styles.emptyIconWrap}>
|
||||
<Icon name="people-outline" size={32} color={colors.icon} />
|
||||
</View>
|
||||
<Text style={styles.emptyTitle}>No Leads Found</Text>
|
||||
<Text style={styles.emptyText}>
|
||||
{searchTerm
|
||||
? `No leads match "${searchTerm}". Try a different search.`
|
||||
: 'You have no leads yet. New leads will appear here.'}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
/>
|
||||
|
||||
@ -7,9 +7,22 @@ export const getStyles = (colors: ThemeColors) =>
|
||||
flex: 1,
|
||||
backgroundColor: colors.background,
|
||||
},
|
||||
countsLoading: {
|
||||
height: 82,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
countsScroll: {
|
||||
paddingHorizontal: 16,
|
||||
paddingTop: 4,
|
||||
paddingBottom: 10,
|
||||
gap: 10,
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
padding: 16,
|
||||
paddingHorizontal: 16,
|
||||
paddingTop: 12,
|
||||
paddingBottom: 10,
|
||||
alignItems: 'center',
|
||||
gap: 10,
|
||||
},
|
||||
@ -22,13 +35,20 @@ export const getStyles = (colors: ThemeColors) =>
|
||||
backgroundColor: colors.surface,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
borderRadius: 12,
|
||||
borderRadius: 14,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
// Branded indigo shadow
|
||||
shadowColor: '#5B4CF5',
|
||||
shadowOffset: { width: 0, height: 3 },
|
||||
shadowOpacity: 0.12,
|
||||
shadowRadius: 6,
|
||||
elevation: 3,
|
||||
},
|
||||
listContent: {
|
||||
padding: 16,
|
||||
paddingTop: 0,
|
||||
paddingHorizontal: 16,
|
||||
paddingTop: 6,
|
||||
paddingBottom: 40,
|
||||
},
|
||||
errorContainer: {
|
||||
flex: 1,
|
||||
@ -42,14 +62,40 @@ export const getStyles = (colors: ThemeColors) =>
|
||||
textAlign: 'center',
|
||||
},
|
||||
emptyContainer: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
paddingTop: 80,
|
||||
padding: 32,
|
||||
gap: 10,
|
||||
},
|
||||
emptyIconWrap: {
|
||||
width: 76,
|
||||
height: 76,
|
||||
borderRadius: 38,
|
||||
backgroundColor: colors.surface,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
padding: 32,
|
||||
marginBottom: 10,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
// Subtle shadow
|
||||
shadowColor: '#5B4CF5',
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.10,
|
||||
shadowRadius: 10,
|
||||
elevation: 3,
|
||||
},
|
||||
emptyTitle: {
|
||||
color: colors.text,
|
||||
fontSize: 17,
|
||||
fontWeight: '700',
|
||||
textAlign: 'center',
|
||||
letterSpacing: 0.1,
|
||||
},
|
||||
emptyText: {
|
||||
color: colors.textSecondary,
|
||||
fontSize: 14,
|
||||
fontSize: 13,
|
||||
textAlign: 'center',
|
||||
lineHeight: 20,
|
||||
maxWidth: 280,
|
||||
},
|
||||
});
|
||||
|
||||
@ -1,17 +1,23 @@
|
||||
import { createReducer } from '@reduxjs/toolkit';
|
||||
import { getLeads } from './thunk';
|
||||
import { LeadItem } from '@interfaces';
|
||||
import { getLeads, getLeadCountList } from './thunk';
|
||||
import { LeadItem, LeadCountItem } from '@interfaces';
|
||||
|
||||
export interface LeadsState {
|
||||
items: LeadItem[];
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
counts: LeadCountItem[];
|
||||
countsLoading: boolean;
|
||||
countsError: string | null;
|
||||
}
|
||||
|
||||
const initialState: LeadsState = {
|
||||
items: [],
|
||||
loading: false,
|
||||
error: null,
|
||||
counts: [],
|
||||
countsLoading: false,
|
||||
countsError: null,
|
||||
};
|
||||
|
||||
export const reducers = createReducer(initialState, builder => {
|
||||
@ -31,6 +37,22 @@ export const reducers = createReducer(initialState, builder => {
|
||||
(action.payload as string) ??
|
||||
action.error.message ??
|
||||
'Failed to load leads';
|
||||
})
|
||||
.addCase(getLeadCountList.pending, acc => {
|
||||
acc.countsLoading = true;
|
||||
acc.countsError = null;
|
||||
})
|
||||
.addCase(getLeadCountList.fulfilled, (acc, action) => {
|
||||
acc.countsLoading = false;
|
||||
acc.counts = action.payload;
|
||||
acc.countsError = null;
|
||||
})
|
||||
.addCase(getLeadCountList.rejected, (acc, action) => {
|
||||
acc.countsLoading = false;
|
||||
acc.countsError =
|
||||
(action.payload as string) ??
|
||||
action.error.message ??
|
||||
'Failed to load lead counts';
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { createAsyncThunk } from '@reduxjs/toolkit';
|
||||
import { getLeadsApi } from '@api';
|
||||
import { LeadItem } from '@interfaces';
|
||||
import { getLeadsApi, getLeadCountListApi } from '@api';
|
||||
import { LeadItem, LeadCountItem } from '@interfaces';
|
||||
|
||||
export const getLeads = createAsyncThunk<
|
||||
LeadItem[],
|
||||
@ -12,3 +12,14 @@ export const getLeads = createAsyncThunk<
|
||||
return rejectWithValue(error.message || 'Failed to load leads');
|
||||
}
|
||||
});
|
||||
|
||||
export const getLeadCountList = createAsyncThunk<
|
||||
LeadCountItem[],
|
||||
string
|
||||
>('leads/getLeadCountList', async (staffid, { rejectWithValue }) => {
|
||||
try {
|
||||
return await getLeadCountListApi(staffid);
|
||||
} catch (error: any) {
|
||||
return rejectWithValue(error.message || 'Failed to load lead counts');
|
||||
}
|
||||
});
|
||||
|
||||
@ -2,29 +2,37 @@ import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Text,
|
||||
View,
|
||||
TextInput,
|
||||
TouchableOpacity,
|
||||
KeyboardAvoidingView,
|
||||
Platform,
|
||||
ScrollView,
|
||||
ImageBackground,
|
||||
ActivityIndicator,
|
||||
StatusBar,
|
||||
} from 'react-native';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
import Icon from 'react-native-vector-icons/Ionicons';
|
||||
import { getStyles } from './login.styles';
|
||||
import { useTheme } from '../../theme';
|
||||
import { useAppDispatch, useAppSelector } from '../../store/store';
|
||||
import { login } from '../../store/commonReducers/auth/thunk';
|
||||
import { useTheme } from '@theme';
|
||||
import { useAppDispatch, useAppSelector, RootState, login } from '@store';
|
||||
import { LoginRequest } from '@interfaces';
|
||||
import { RootState } from '../../store/rootReducer';
|
||||
import { NotificationService } from '@services';
|
||||
import { backGroundImage } from '@utils';
|
||||
import { FormInput } from '@components';
|
||||
import config from 'react-native-config';
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
|
||||
export const LoginScreen = () => {
|
||||
const dispatch = useAppDispatch();
|
||||
const navigation = useNavigation<any>();
|
||||
const { theme: colors } = useTheme();
|
||||
const styles = getStyles(colors);
|
||||
const { theme: colors, isDark } = useTheme();
|
||||
const styles = getStyles(colors, isDark);
|
||||
|
||||
const [tenancy, setTenancy] = useState('');
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [focusedField, setFocusedField] = useState<string | null>(null);
|
||||
const [localError, setLocalError] = useState('');
|
||||
|
||||
const { loginLoading, loginError, loginSuccess, token, user_data } =
|
||||
@ -40,19 +48,68 @@ export const LoginScreen = () => {
|
||||
setLocalError('Email address is required');
|
||||
return;
|
||||
}
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
if (!emailRegex.test(email.trim())) {
|
||||
setLocalError('Please enter a valid email address');
|
||||
return;
|
||||
}
|
||||
if (!password.trim()) {
|
||||
setLocalError('Password is required');
|
||||
return;
|
||||
}
|
||||
|
||||
const deviceToken = await NotificationService.getFCMToken();
|
||||
console.log('deviceToken-', deviceToken)
|
||||
|
||||
const payload: LoginRequest = {
|
||||
email,
|
||||
password,
|
||||
device_token: '',
|
||||
device_token: deviceToken ?? '',
|
||||
};
|
||||
console.log('payload-', payload)
|
||||
console.log('tenancy-', config.BASE_URL)
|
||||
if (tenancy == config.BASE_URL) {
|
||||
try {
|
||||
await AsyncStorage.setItem('base_url', tenancy);
|
||||
await AsyncStorage.setItem('email', email);
|
||||
await AsyncStorage.setItem('password', password);
|
||||
} catch (e) {
|
||||
console.error('Failed to save login credentials to AsyncStorage', e);
|
||||
}
|
||||
await dispatch(login(payload));
|
||||
} else {
|
||||
setLocalError('Invalid tenancy name');
|
||||
}
|
||||
};
|
||||
|
||||
await dispatch(login(payload));
|
||||
useEffect(() => {
|
||||
const loadCredentials = async () => {
|
||||
try {
|
||||
const savedBaseUrl = await AsyncStorage.getItem('base_url');
|
||||
const savedEmail = await AsyncStorage.getItem('email');
|
||||
const savedPassword = await AsyncStorage.getItem('password');
|
||||
|
||||
if (savedBaseUrl) setTenancy(savedBaseUrl);
|
||||
if (savedEmail) setEmail(savedEmail);
|
||||
if (savedPassword) setPassword(savedPassword);
|
||||
} catch (e) {
|
||||
console.error('Failed to load login credentials from AsyncStorage', e);
|
||||
}
|
||||
};
|
||||
loadCredentials();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
if (
|
||||
tenancy.trim() &&
|
||||
email.trim() &&
|
||||
emailRegex.test(email.trim()) &&
|
||||
password.trim()
|
||||
) {
|
||||
setLocalError('');
|
||||
}
|
||||
}, [tenancy, email, password]);
|
||||
|
||||
useEffect(() => {
|
||||
if (loginSuccess && token && user_data) {
|
||||
@ -64,117 +121,164 @@ export const LoginScreen = () => {
|
||||
}, [loginSuccess, token, user_data, navigation]);
|
||||
|
||||
return (
|
||||
<ImageBackground
|
||||
source={backGroundImage}
|
||||
style={styles.backgroundImage}
|
||||
resizeMode="cover"
|
||||
>
|
||||
<StatusBar
|
||||
barStyle={isDark ? 'light-content' : 'dark-content'}
|
||||
backgroundColor="transparent"
|
||||
translucent
|
||||
/>
|
||||
<View style={styles.overlay}>
|
||||
<KeyboardAvoidingView
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
|
||||
style={styles.container}
|
||||
>
|
||||
<ScrollView
|
||||
contentContainerStyle={styles.scrollContainer}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
showsVerticalScrollIndicator={false}
|
||||
bounces={false}
|
||||
>
|
||||
<View style={styles.headerContainer}>
|
||||
<View style={styles.logoCircle}>
|
||||
<Icon name="cube" size={40} color={colors.icon} />
|
||||
</View>
|
||||
<Text style={styles.title}>Convex CRM</Text>
|
||||
<Text style={styles.subtitle}>
|
||||
Enter details to access your workspace
|
||||
{/* Top Area Spacer displaying background illustration */}
|
||||
<View style={styles.topSpacer} />
|
||||
|
||||
{/* Bottom Sheet Form Card */}
|
||||
<View style={styles.bottomSheetCard}>
|
||||
<View style={styles.sheetHandle} />
|
||||
|
||||
<View style={styles.cardHeader}>
|
||||
<Text style={styles.welcomeTitle}>Sign In</Text>
|
||||
<Text style={styles.welcomeSubtitle}>
|
||||
Enter your credentials to continue
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.formCard}>
|
||||
{localError || loginError ? (
|
||||
<View style={styles.errorBanner}>
|
||||
<Icon name="alert-circle" size={20} color="#EF4444" />
|
||||
<Icon name="alert-circle-outline" size={20} color="#EF4444" />
|
||||
<Text style={styles.errorText}>{localError || loginError}</Text>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{/* Tenancy Field */}
|
||||
<View style={styles.inputContainer}>
|
||||
<Text style={styles.label}>Tenancy Name</Text>
|
||||
<View style={styles.inputWrapper}>
|
||||
<FormInput
|
||||
label="Tenancy Name"
|
||||
value={tenancy}
|
||||
onChangeText={setTenancy}
|
||||
placeholder="company-name"
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
containerStyle={styles.fieldContainer}
|
||||
inputContainerStyle={[
|
||||
styles.inputWrapper,
|
||||
focusedField === 'tenancy' && styles.inputWrapperFocused,
|
||||
]}
|
||||
onFocus={() => setFocusedField('tenancy')}
|
||||
onBlur={() => setFocusedField(null)}
|
||||
leftIcon={
|
||||
<Icon
|
||||
name="business-outline"
|
||||
size={20}
|
||||
color={colors.textSecondary}
|
||||
style={styles.inputIcon}
|
||||
color={
|
||||
focusedField === 'tenancy'
|
||||
? colors.icon
|
||||
: colors.textSecondary
|
||||
}
|
||||
/>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder="company-name"
|
||||
placeholderTextColor={colors.textMuted}
|
||||
value={tenancy}
|
||||
onChangeText={setTenancy}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Email Field */}
|
||||
<View style={styles.inputContainer}>
|
||||
<Text style={styles.label}>Email Address</Text>
|
||||
<View style={styles.inputWrapper}>
|
||||
<Icon
|
||||
name="mail-outline"
|
||||
size={20}
|
||||
color={colors.textSecondary}
|
||||
style={styles.inputIcon}
|
||||
/>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder="name@company.com"
|
||||
placeholderTextColor={colors.textMuted}
|
||||
<FormInput
|
||||
label="Email Address"
|
||||
value={email}
|
||||
onChangeText={setEmail}
|
||||
placeholder="name@company.com"
|
||||
keyboardType="email-address"
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
containerStyle={styles.fieldContainer}
|
||||
inputContainerStyle={[
|
||||
styles.inputWrapper,
|
||||
focusedField === 'email' && styles.inputWrapperFocused,
|
||||
]}
|
||||
onFocus={() => setFocusedField('email')}
|
||||
onBlur={() => setFocusedField(null)}
|
||||
leftIcon={
|
||||
<Icon
|
||||
name="mail-outline"
|
||||
size={20}
|
||||
color={
|
||||
focusedField === 'email'
|
||||
? colors.icon
|
||||
: colors.textSecondary
|
||||
}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Password Field */}
|
||||
<View style={styles.inputContainer}>
|
||||
<Text style={styles.label}>Password</Text>
|
||||
<View style={styles.inputWrapper}>
|
||||
<FormInput
|
||||
label="Password"
|
||||
value={password}
|
||||
onChangeText={setPassword}
|
||||
placeholder="••••••••"
|
||||
secureTextEntry={!showPassword}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
containerStyle={styles.fieldContainer}
|
||||
inputContainerStyle={[
|
||||
styles.inputWrapper,
|
||||
focusedField === 'password' && styles.inputWrapperFocused,
|
||||
]}
|
||||
onFocus={() => setFocusedField('password')}
|
||||
onBlur={() => setFocusedField(null)}
|
||||
leftIcon={
|
||||
<Icon
|
||||
name="lock-closed-outline"
|
||||
size={20}
|
||||
color={colors.textSecondary}
|
||||
style={styles.inputIcon}
|
||||
color={
|
||||
focusedField === 'password'
|
||||
? colors.icon
|
||||
: colors.textSecondary
|
||||
}
|
||||
/>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder="••••••••"
|
||||
placeholderTextColor={colors.textMuted}
|
||||
value={password}
|
||||
onChangeText={setPassword}
|
||||
secureTextEntry
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
}
|
||||
rightIcon={
|
||||
<TouchableOpacity
|
||||
onPress={() => setShowPassword(!showPassword)}
|
||||
style={styles.eyeIconButton}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Icon
|
||||
name={showPassword ? 'eye-off-outline' : 'eye-outline'}
|
||||
size={20}
|
||||
color={colors.textMuted}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Sign In Button */}
|
||||
<TouchableOpacity
|
||||
style={styles.button}
|
||||
activeOpacity={0.8}
|
||||
style={[styles.button, loginLoading && styles.buttonDisabled]}
|
||||
activeOpacity={0.85}
|
||||
onPress={handleLogin}
|
||||
disabled={loginLoading}
|
||||
>
|
||||
<Text style={styles.buttonText}>
|
||||
{loginLoading ? 'Signing In...' : 'Sign In'}
|
||||
</Text>
|
||||
{loginLoading ? (
|
||||
<ActivityIndicator size="small" color="#FFFFFF" />
|
||||
) : (
|
||||
<Text style={styles.buttonText}>Sign In</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<View style={styles.footer}>
|
||||
<Text style={styles.footerText}>Secure SSL encrypted workspace</Text>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</KeyboardAvoidingView>
|
||||
</View>
|
||||
</ImageBackground>
|
||||
);
|
||||
};
|
||||
|
||||
@ -1,128 +1,129 @@
|
||||
import { StyleSheet } from 'react-native';
|
||||
import { StyleSheet, Platform } from 'react-native';
|
||||
import { ThemeColors } from '../../theme';
|
||||
|
||||
export const getStyles = (colors: ThemeColors) =>
|
||||
export const getStyles = (colors: ThemeColors, isDark: boolean) =>
|
||||
StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: colors.background,
|
||||
},
|
||||
backgroundImage: {
|
||||
flex: 1,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
},
|
||||
overlay: {
|
||||
flex: 1,
|
||||
backgroundColor: isDark
|
||||
? 'rgba(10, 12, 26, 0.75)'
|
||||
: 'rgba(235, 242, 250, 0.35)',
|
||||
},
|
||||
scrollContainer: {
|
||||
flexGrow: 1,
|
||||
justifyContent: 'center',
|
||||
padding: 24,
|
||||
justifyContent: 'space-between',
|
||||
},
|
||||
headerContainer: {
|
||||
alignItems: 'center',
|
||||
marginBottom: 32,
|
||||
topSpacer: {
|
||||
flex: 1,
|
||||
minHeight: 120,
|
||||
},
|
||||
logoCircle: {
|
||||
width: 80,
|
||||
height: 80,
|
||||
borderRadius: 40,
|
||||
backgroundColor: colors.border,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
bottomSheetCard: {
|
||||
backgroundColor: isDark
|
||||
? 'rgba(26, 29, 58, 0.96)'
|
||||
: 'rgba(255, 255, 255, 0.98)',
|
||||
borderTopLeftRadius: 32,
|
||||
borderTopRightRadius: 32,
|
||||
borderBottomLeftRadius: 0,
|
||||
borderBottomRightRadius: 0,
|
||||
paddingHorizontal: 24,
|
||||
paddingTop: 14,
|
||||
paddingBottom: Platform.OS === 'ios' ? 36 : 24,
|
||||
borderWidth: 1,
|
||||
borderBottomWidth: 0,
|
||||
borderColor: isDark
|
||||
? 'rgba(255, 255, 255, 0.1)'
|
||||
: 'rgba(228, 232, 245, 0.9)',
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: -8 },
|
||||
shadowOpacity: isDark ? 0.4 : 0.12,
|
||||
shadowRadius: 20,
|
||||
elevation: 16,
|
||||
},
|
||||
sheetHandle: {
|
||||
width: 36,
|
||||
height: 4,
|
||||
borderRadius: 2,
|
||||
backgroundColor: isDark ? 'rgba(255, 255, 255, 0.2)' : '#CBD5E1',
|
||||
alignSelf: 'center',
|
||||
marginBottom: 16,
|
||||
shadowColor: colors.icon,
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.3,
|
||||
shadowRadius: 10,
|
||||
elevation: 8,
|
||||
},
|
||||
title: {
|
||||
fontSize: 28,
|
||||
cardHeader: {
|
||||
marginBottom: 18,
|
||||
},
|
||||
welcomeTitle: {
|
||||
fontSize: 22,
|
||||
fontWeight: '800',
|
||||
color: colors.text,
|
||||
letterSpacing: 0.5,
|
||||
letterSpacing: 0.2,
|
||||
},
|
||||
subtitle: {
|
||||
fontSize: 14,
|
||||
welcomeSubtitle: {
|
||||
fontSize: 13,
|
||||
color: colors.textSecondary,
|
||||
marginTop: 8,
|
||||
textAlign: 'center',
|
||||
},
|
||||
formCard: {
|
||||
backgroundColor: colors.card,
|
||||
borderRadius: 16,
|
||||
padding: 24,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 10 },
|
||||
shadowOpacity: 0.25,
|
||||
shadowRadius: 15,
|
||||
elevation: 10,
|
||||
marginTop: 4,
|
||||
fontWeight: '400',
|
||||
},
|
||||
errorBanner: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: '#451A1A',
|
||||
borderRadius: 8,
|
||||
backgroundColor: isDark ? 'rgba(239, 68, 68, 0.15)' : '#FEF2F2',
|
||||
borderRadius: 12,
|
||||
padding: 12,
|
||||
marginBottom: 16,
|
||||
borderWidth: 1,
|
||||
borderColor: '#7F1D1D',
|
||||
borderColor: isDark ? 'rgba(239, 68, 68, 0.3)' : '#FCA5A5',
|
||||
},
|
||||
errorText: {
|
||||
color: '#FCA5A5',
|
||||
color: '#EF4444',
|
||||
fontSize: 13,
|
||||
marginLeft: 8,
|
||||
fontWeight: '500',
|
||||
marginLeft: 10,
|
||||
flex: 1,
|
||||
},
|
||||
inputContainer: {
|
||||
marginBottom: 20,
|
||||
},
|
||||
label: {
|
||||
fontSize: 13,
|
||||
fontWeight: '600',
|
||||
color: colors.textSecondary,
|
||||
marginBottom: 8,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.5,
|
||||
fieldContainer: {
|
||||
marginBottom: 14,
|
||||
},
|
||||
inputWrapper: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: colors.background,
|
||||
borderRadius: 10,
|
||||
borderWidth: 1,
|
||||
borderBottomColor: colors.border,
|
||||
backgroundColor: isDark ? 'rgba(15, 16, 34, 0.6)' : '#F8FAFC',
|
||||
borderRadius: 14,
|
||||
borderWidth: 1.5,
|
||||
borderColor: colors.border,
|
||||
paddingHorizontal: 12,
|
||||
height: 50,
|
||||
},
|
||||
inputIcon: {
|
||||
marginRight: 10,
|
||||
inputWrapperFocused: {
|
||||
borderColor: colors.icon,
|
||||
backgroundColor: isDark ? 'rgba(15, 16, 34, 0.85)' : '#FFFFFF',
|
||||
},
|
||||
input: {
|
||||
flex: 1,
|
||||
height: 48,
|
||||
color: colors.text,
|
||||
fontSize: 15,
|
||||
eyeIconButton: {
|
||||
padding: 6,
|
||||
},
|
||||
button: {
|
||||
backgroundColor: colors.icon,
|
||||
borderRadius: 10,
|
||||
height: 50,
|
||||
borderRadius: 14,
|
||||
height: 52,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginTop: 8,
|
||||
marginTop: 10,
|
||||
shadowColor: colors.icon,
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.4,
|
||||
shadowRadius: 6,
|
||||
elevation: 5,
|
||||
shadowOffset: { width: 0, height: 6 },
|
||||
shadowOpacity: 0.35,
|
||||
shadowRadius: 12,
|
||||
elevation: 6,
|
||||
},
|
||||
buttonDisabled: {
|
||||
opacity: 0.7,
|
||||
},
|
||||
buttonText: {
|
||||
color: '#FFFFFF',
|
||||
fontSize: 16,
|
||||
fontWeight: '700',
|
||||
letterSpacing: 0.5,
|
||||
},
|
||||
footer: {
|
||||
alignItems: 'center',
|
||||
marginTop: 32,
|
||||
},
|
||||
footerText: {
|
||||
fontSize: 12,
|
||||
color: colors.textMuted,
|
||||
letterSpacing: 0.4,
|
||||
},
|
||||
});
|
||||
|
||||
@ -1,20 +1,21 @@
|
||||
import React from 'react';
|
||||
import { Text, View, TouchableOpacity, ScrollView, Switch } from 'react-native';
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
import Icon from 'react-native-vector-icons/Ionicons';
|
||||
import { getStyles } from './profile.styles';
|
||||
import { useTheme } from '../../theme';
|
||||
|
||||
import { useAppDispatch, logout } from '@store';
|
||||
|
||||
export const ProfileScreen = () => {
|
||||
const navigation = useNavigation<any>();
|
||||
const { theme: colors, isDark, toggleTheme } = useTheme();
|
||||
const styles = getStyles(colors);
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const handleLogout = async () => {
|
||||
try {
|
||||
await AsyncStorage.removeItem('userToken');
|
||||
await AsyncStorage.removeItem('tenancyName');
|
||||
dispatch(logout());
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
|
||||
@ -2,3 +2,4 @@ export * from './drawerItem';
|
||||
export * from './auth';
|
||||
export * from './leads';
|
||||
export * from './leadDetails';
|
||||
export * from './list';
|
||||
|
||||
@ -46,3 +46,12 @@ export interface LeadItem {
|
||||
status_name: string;
|
||||
source_name: string;
|
||||
}
|
||||
|
||||
export interface LeadCountItem {
|
||||
id: string;
|
||||
name: string;
|
||||
statusorder: string | number;
|
||||
color: string;
|
||||
isdefault: string | number;
|
||||
count: number;
|
||||
}
|
||||
|
||||
25
app/interfaces/list.ts
Normal file
@ -0,0 +1,25 @@
|
||||
export interface StatusListItem {
|
||||
id: string;
|
||||
name: string;
|
||||
statusorder: string;
|
||||
color: string;
|
||||
isdefault: string;
|
||||
}
|
||||
|
||||
export interface SourceListItem {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface CountryListItem {
|
||||
country_id: string;
|
||||
iso2: string;
|
||||
short_name: string;
|
||||
long_name: string;
|
||||
iso3: string;
|
||||
numcode: string;
|
||||
un_member: string;
|
||||
calling_code: string;
|
||||
cctld: string;
|
||||
}
|
||||
|
||||
@ -1,13 +1,14 @@
|
||||
import React from 'react';
|
||||
import { Text, View, TouchableOpacity, ScrollView } from 'react-native';
|
||||
import { DrawerContentComponentProps } from '@react-navigation/drawer';
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import Icon from 'react-native-vector-icons/Ionicons';
|
||||
import { menuItems } from '@mock-data';
|
||||
import { useTheme } from '@theme';
|
||||
import { DrawerItemProps } from '@interfaces';
|
||||
import { getStyles } from './customDrawerContent.style';
|
||||
|
||||
import { useAppDispatch, logout } from '@store';
|
||||
|
||||
const DrawerItem = ({ label, iconName, focused, onPress }: DrawerItemProps) => {
|
||||
const { theme: colors } = useTheme();
|
||||
const styles = getStyles(colors);
|
||||
@ -35,11 +36,11 @@ export const CustomDrawerContent = (props: DrawerContentComponentProps) => {
|
||||
const { state, navigation } = props;
|
||||
const { theme: colors } = useTheme();
|
||||
const styles = getStyles(colors);
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const handleLogout = async () => {
|
||||
try {
|
||||
await AsyncStorage.removeItem('userToken');
|
||||
await AsyncStorage.removeItem('tenancyName');
|
||||
dispatch(logout());
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
|
||||
@ -29,6 +29,7 @@ const Drawer = createDrawerNavigator<DrawerStackParamList>();
|
||||
|
||||
export const DrawerStack = () => {
|
||||
const { theme: colors } = useTheme();
|
||||
|
||||
return (
|
||||
<Drawer.Navigator
|
||||
drawerContent={CustomDrawerContent}
|
||||
@ -36,19 +37,17 @@ export const DrawerStack = () => {
|
||||
swipeEnabled: false,
|
||||
headerStyle: {
|
||||
backgroundColor: colors.header,
|
||||
elevation: 0,
|
||||
shadowOpacity: 0,
|
||||
},
|
||||
headerTitleStyle: {
|
||||
fontSize: 16,
|
||||
fontSize: 17,
|
||||
fontWeight: '700',
|
||||
color: colors.text,
|
||||
letterSpacing: 0.2,
|
||||
},
|
||||
headerTitleAlign: 'center',
|
||||
headerTintColor: colors.text,
|
||||
}}
|
||||
>
|
||||
{/* Tab Navigator has its own headers per tab, so hide Drawer header for home */}
|
||||
headerShadowVisible: false,
|
||||
}}>
|
||||
<Drawer.Screen
|
||||
name="home"
|
||||
component={TabStack}
|
||||
|
||||
@ -27,17 +27,18 @@ export const LeadsStack = () => {
|
||||
backgroundColor: colors.header,
|
||||
},
|
||||
headerTitleStyle: {
|
||||
fontSize: 16,
|
||||
fontSize: 17,
|
||||
fontWeight: '700',
|
||||
color: colors.text,
|
||||
},
|
||||
headerTitleAlign: 'center',
|
||||
headerTintColor: colors.text,
|
||||
headerShadowVisible: false,
|
||||
}}>
|
||||
<Stack.Screen
|
||||
name={route.leads}
|
||||
component={LeadsScreen}
|
||||
options={{ headerTitle: 'Leads' }}
|
||||
options={{ headerTitle: 'My Leads' }}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name={route.leadDetails}
|
||||
|
||||
@ -15,7 +15,6 @@ const RootStack = createNativeStackNavigator<RootStackParamList>();
|
||||
|
||||
export const RootNavigator = () => {
|
||||
const { token } = useAppSelector((state: RootState) => state.auth);
|
||||
console.log('RootNavigator token:', token); // Debugging line to check the token value
|
||||
const initialRouteName = token ? 'DrawerStack' : 'AuthStack';
|
||||
|
||||
return (
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { StyleSheet } from 'react-native';
|
||||
import { StyleSheet, Platform } from 'react-native';
|
||||
import { ThemeColors } from '@theme';
|
||||
|
||||
export const getStyles = (colors: ThemeColors) =>
|
||||
@ -7,30 +7,45 @@ export const getStyles = (colors: ThemeColors) =>
|
||||
backgroundColor: colors.tabBar,
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: colors.border,
|
||||
height: 60,
|
||||
paddingBottom: 8,
|
||||
paddingTop: 8,
|
||||
height: Platform.OS === 'ios' ? 84 : 64,
|
||||
paddingBottom: Platform.OS === 'ios' ? 24 : 10,
|
||||
// paddingTop: 8,
|
||||
shadowColor: '#5B4CF5',
|
||||
shadowOffset: { width: 0, height: -4 },
|
||||
shadowOpacity: 0.06,
|
||||
shadowRadius: 12,
|
||||
elevation: 8,
|
||||
},
|
||||
tabBarLabel: {
|
||||
fontSize: 11,
|
||||
fontSize: 12,
|
||||
fontWeight: '600',
|
||||
letterSpacing: 0.2,
|
||||
},
|
||||
header: {
|
||||
backgroundColor: colors.header,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: colors.border,
|
||||
elevation: 0,
|
||||
shadowOpacity: 0,
|
||||
shadowColor: '#5B4CF5',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.04,
|
||||
shadowRadius: 6,
|
||||
elevation: 2,
|
||||
},
|
||||
headerTitle: {
|
||||
fontSize: 17,
|
||||
fontWeight: '800',
|
||||
fontWeight: '700',
|
||||
color: colors.text,
|
||||
letterSpacing: 0.2,
|
||||
},
|
||||
drawerButton: {
|
||||
paddingHorizontal: 16,
|
||||
height: '100%',
|
||||
width: 38,
|
||||
height: 38,
|
||||
borderRadius: 12,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginLeft: 14,
|
||||
backgroundColor: colors.surface,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
},
|
||||
});
|
||||
|
||||
@ -37,11 +37,11 @@ export const TabStack = () => {
|
||||
} else if (tabRoute.name === route.addLead) {
|
||||
iconName = focused ? 'add-circle' : 'add-circle-outline';
|
||||
} else if (tabRoute.name === route.customers) {
|
||||
iconName = focused ? 'business' : 'business-outline';
|
||||
iconName = focused ? 'people' : 'people-outline';
|
||||
} else if (tabRoute.name === route.profile) {
|
||||
iconName = focused ? 'person' : 'person-outline';
|
||||
}
|
||||
return <Icon name={iconName} size={size} color={color} />;
|
||||
return <Icon name={iconName} size={size || 22} color={color} />;
|
||||
},
|
||||
tabBarActiveTintColor: colors.icon,
|
||||
tabBarInactiveTintColor: colors.textMuted,
|
||||
@ -50,28 +50,26 @@ export const TabStack = () => {
|
||||
headerStyle: styles.header,
|
||||
headerTitleStyle: styles.headerTitle,
|
||||
headerTitleAlign: 'center',
|
||||
})}
|
||||
>
|
||||
})}>
|
||||
<Tab.Screen
|
||||
name={route.dashboard}
|
||||
component={DashboardScreen}
|
||||
options={({ navigation }) => ({
|
||||
tabBarLabel: 'Dashboard',
|
||||
headerTitle: 'Convex CRM',
|
||||
headerLeft: () => (
|
||||
<TouchableOpacity
|
||||
style={styles.drawerButton}
|
||||
activeOpacity={0.7}
|
||||
onPress={() => {
|
||||
const parentNav =
|
||||
navigation.getParent<DrawerNavigationProp<any>>();
|
||||
const parentNav = navigation.getParent<DrawerNavigationProp<any>>();
|
||||
if (parentNav) {
|
||||
parentNav.openDrawer();
|
||||
} else {
|
||||
(navigation as any).openDrawer?.();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Icon name="menu-outline" size={26} color={colors.text} />
|
||||
}}>
|
||||
<Icon name="menu-outline" size={20} color={colors.text} />
|
||||
</TouchableOpacity>
|
||||
),
|
||||
})}
|
||||
@ -79,11 +77,35 @@ export const TabStack = () => {
|
||||
<Tab.Screen
|
||||
name={route.leads}
|
||||
component={LeadsStack}
|
||||
options={{ headerShown: false }}
|
||||
options={{
|
||||
tabBarLabel: 'Leads',
|
||||
headerShown: false,
|
||||
}}
|
||||
/>
|
||||
<Tab.Screen
|
||||
name={route.addLead}
|
||||
component={AddLeadScreen}
|
||||
options={{
|
||||
tabBarLabel: 'Add',
|
||||
headerTitle: 'Add Lead',
|
||||
}}
|
||||
/>
|
||||
<Tab.Screen
|
||||
name={route.customers}
|
||||
component={CustomersScreen}
|
||||
options={{
|
||||
tabBarLabel: 'Customers',
|
||||
headerTitle: 'Customers',
|
||||
}}
|
||||
/>
|
||||
<Tab.Screen
|
||||
name={route.profile}
|
||||
component={ProfileScreen}
|
||||
options={{
|
||||
tabBarLabel: 'Profile',
|
||||
headerTitle: 'Profile',
|
||||
}}
|
||||
/>
|
||||
<Tab.Screen name={route.addLead} component={AddLeadScreen} />
|
||||
<Tab.Screen name={route.customers} component={CustomersScreen} />
|
||||
<Tab.Screen name={route.profile} component={ProfileScreen} />
|
||||
</Tab.Navigator>
|
||||
);
|
||||
};
|
||||
|
||||
1
app/services/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from './notifications';
|
||||
310
app/services/notifications.ts
Normal file
@ -0,0 +1,310 @@
|
||||
import messaging, {
|
||||
FirebaseMessagingTypes,
|
||||
} from '@react-native-firebase/messaging';
|
||||
import notifee, {
|
||||
AndroidImportance,
|
||||
AndroidVisibility,
|
||||
AuthorizationStatus,
|
||||
EventType,
|
||||
} from '@notifee/react-native';
|
||||
import { Platform } from 'react-native';
|
||||
|
||||
const CHANNEL_ID = 'convex_crm_default';
|
||||
const CHANNEL_NAME = 'Convex CRM Notifications';
|
||||
const CHANNEL_DESCRIPTION = 'General push notifications for Convex CRM';
|
||||
|
||||
export interface NotificationData {
|
||||
[key: string]: string;
|
||||
}
|
||||
|
||||
export type MessageHandler = (
|
||||
message: FirebaseMessagingTypes.RemoteMessage,
|
||||
) => void;
|
||||
|
||||
export type NotificationOpenHandler = (data: NotificationData) => void;
|
||||
|
||||
export async function requestPermission(): Promise<boolean> {
|
||||
try {
|
||||
const settings = await notifee.requestPermission();
|
||||
const granted =
|
||||
settings.authorizationStatus === AuthorizationStatus.AUTHORIZED ||
|
||||
settings.authorizationStatus === AuthorizationStatus.PROVISIONAL;
|
||||
console.log('[NotificationService] Permission granted:', granted);
|
||||
return granted;
|
||||
} catch (error) {
|
||||
console.error('[NotificationService] requestPermission error:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function createDefaultChannel(): Promise<void> {
|
||||
if (Platform.OS !== 'android') {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await notifee.createChannel({
|
||||
id: CHANNEL_ID,
|
||||
name: CHANNEL_NAME,
|
||||
description: CHANNEL_DESCRIPTION,
|
||||
importance: AndroidImportance.HIGH,
|
||||
visibility: AndroidVisibility.PUBLIC,
|
||||
sound: 'default',
|
||||
vibration: true,
|
||||
vibrationPattern: [300, 500],
|
||||
lights: true,
|
||||
lightColor: '#4F46E5', // indigo accent
|
||||
});
|
||||
console.log('[NotificationService] Default Android channel created:', CHANNEL_ID);
|
||||
} catch (error) {
|
||||
console.error('[NotificationService] createDefaultChannel error:', error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function getFCMToken(): Promise<string | null> {
|
||||
try {
|
||||
// Ensure FCM is registered to receive messages
|
||||
if (!messaging().isDeviceRegisteredForRemoteMessages) {
|
||||
await messaging().registerDeviceForRemoteMessages();
|
||||
}
|
||||
const token = await messaging().getToken();
|
||||
console.log('[NotificationService] FCM Token:', token);
|
||||
return token;
|
||||
} catch (error) {
|
||||
console.error('[NotificationService] getFCMToken error:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to token-refresh events.
|
||||
* Call this once; the returned function unsubscribes when invoked.
|
||||
*/
|
||||
export function onTokenRefresh(
|
||||
callback: (token: string) => void,
|
||||
): () => void {
|
||||
const unsubscribe = messaging().onTokenRefresh(newToken => {
|
||||
console.log('[NotificationService] FCM token refreshed:', newToken);
|
||||
callback(newToken);
|
||||
});
|
||||
return unsubscribe;
|
||||
}
|
||||
|
||||
export async function displayNotification(
|
||||
title: string,
|
||||
body: string,
|
||||
data: NotificationData = {},
|
||||
): Promise<void> {
|
||||
try {
|
||||
await notifee.displayNotification({
|
||||
title,
|
||||
body,
|
||||
data,
|
||||
android: {
|
||||
channelId: CHANNEL_ID,
|
||||
importance: AndroidImportance.HIGH,
|
||||
smallIcon: 'ic_notification', // must exist in android/app/src/main/res/drawable
|
||||
pressAction: { id: 'default' },
|
||||
color: '#4F46E5',
|
||||
},
|
||||
ios: {
|
||||
sound: 'default',
|
||||
foregroundPresentationOptions: {
|
||||
alert: true,
|
||||
badge: true,
|
||||
sound: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[NotificationService] displayNotification error:', error);
|
||||
}
|
||||
}
|
||||
|
||||
export function onForegroundMessage(
|
||||
onOpen?: NotificationOpenHandler,
|
||||
): () => void {
|
||||
// FCM foreground listener
|
||||
const unsubscribeFCM = messaging().onMessage(
|
||||
async (remoteMessage: FirebaseMessagingTypes.RemoteMessage) => {
|
||||
console.log('[NotificationService] Foreground FCM message:', remoteMessage);
|
||||
|
||||
const title =
|
||||
remoteMessage.notification?.title ?? 'Convex CRM';
|
||||
const body =
|
||||
remoteMessage.notification?.body ?? 'You have a new notification';
|
||||
const data = (remoteMessage.data ?? {}) as NotificationData;
|
||||
|
||||
await displayNotification(title, body, data);
|
||||
},
|
||||
);
|
||||
|
||||
// Notifee foreground event listener (handles taps on Notifee notifications)
|
||||
const unsubscribeNotifee = notifee.onForegroundEvent(({ type, detail }) => {
|
||||
if (type === EventType.PRESS && detail.notification?.data) {
|
||||
console.log(
|
||||
'[NotificationService] Notifee notification pressed (foreground):',
|
||||
detail.notification.data,
|
||||
);
|
||||
onOpen?.(detail.notification.data as NotificationData);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubscribeFCM();
|
||||
unsubscribeNotifee();
|
||||
};
|
||||
}
|
||||
|
||||
export function registerBackgroundHandler(): void {
|
||||
messaging().setBackgroundMessageHandler(
|
||||
async (remoteMessage: FirebaseMessagingTypes.RemoteMessage) => {
|
||||
console.log(
|
||||
'[NotificationService] Background FCM message:',
|
||||
remoteMessage,
|
||||
);
|
||||
// FCM data-only messages in the background need manual display
|
||||
if (remoteMessage.data && !remoteMessage.notification) {
|
||||
const title =
|
||||
(remoteMessage.data.title as string) ?? 'Convex CRM';
|
||||
const body =
|
||||
(remoteMessage.data.body as string) ?? 'You have a new notification';
|
||||
await displayNotification(title, body, remoteMessage.data as NotificationData);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Notifee background event (handles taps on notifications from background)
|
||||
notifee.onBackgroundEvent(async ({ type, detail }) => {
|
||||
if (type === EventType.PRESS) {
|
||||
console.log(
|
||||
'[NotificationService] Notifee notification pressed (background):',
|
||||
detail.notification?.data,
|
||||
);
|
||||
}
|
||||
if (type === EventType.DISMISSED) {
|
||||
console.log(
|
||||
'[NotificationService] Notification dismissed (background)',
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to FCM notification taps when the app is in the background
|
||||
* (but not fully quit). Returns an unsubscribe function.
|
||||
*/
|
||||
export function onNotificationOpenedApp(
|
||||
callback: NotificationOpenHandler,
|
||||
): () => void {
|
||||
const unsubscribe = messaging().onNotificationOpenedApp(remoteMessage => {
|
||||
console.log(
|
||||
'[NotificationService] App opened from background notification:',
|
||||
remoteMessage,
|
||||
);
|
||||
callback((remoteMessage.data ?? {}) as NotificationData);
|
||||
});
|
||||
return unsubscribe;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether the app was launched by tapping a notification
|
||||
* while it was fully quit. Call once on app startup.
|
||||
*/
|
||||
export async function getInitialNotification(
|
||||
callback: NotificationOpenHandler,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const remoteMessage = await messaging().getInitialNotification();
|
||||
if (remoteMessage) {
|
||||
console.log(
|
||||
'[NotificationService] App launched from quit-state notification:',
|
||||
remoteMessage,
|
||||
);
|
||||
callback((remoteMessage.data ?? {}) as NotificationData);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[NotificationService] getInitialNotification error:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the app icon badge count (iOS only; no-op on Android).
|
||||
*/
|
||||
export async function setBadgeCount(count: number): Promise<void> {
|
||||
try {
|
||||
await notifee.setBadgeCount(count);
|
||||
} catch (error) {
|
||||
console.error('[NotificationService] setBadgeCount error:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the app icon badge (iOS only; no-op on Android).
|
||||
*/
|
||||
export async function clearBadge(): Promise<void> {
|
||||
try {
|
||||
await notifee.setBadgeCount(0);
|
||||
} catch (error) {
|
||||
console.error('[NotificationService] clearBadge error:', error);
|
||||
}
|
||||
}
|
||||
|
||||
export interface InitializeOptions {
|
||||
onNotificationOpen?: NotificationOpenHandler;
|
||||
onTokenRefreshed?: (token: string) => void;
|
||||
}
|
||||
|
||||
export async function initialize(
|
||||
options: InitializeOptions = {},
|
||||
): Promise<() => void> {
|
||||
const { onNotificationOpen, onTokenRefreshed } = options;
|
||||
|
||||
// 1. Permission
|
||||
await requestPermission();
|
||||
|
||||
// 2. Android channel
|
||||
await createDefaultChannel();
|
||||
|
||||
// 3. FCM token
|
||||
await getFCMToken();
|
||||
|
||||
// 4. Token refresh
|
||||
const unsubscribeTokenRefresh = onTokenRefresh(token => {
|
||||
onTokenRefreshed?.(token);
|
||||
});
|
||||
|
||||
// 5. Foreground messages
|
||||
const unsubscribeForeground = onForegroundMessage(onNotificationOpen);
|
||||
|
||||
// 6. Background tap
|
||||
const unsubscribeBackgroundOpen = onNotificationOpenedApp(data => {
|
||||
onNotificationOpen?.(data);
|
||||
});
|
||||
|
||||
// 7. Quit-state launch
|
||||
await getInitialNotification(data => {
|
||||
onNotificationOpen?.(data);
|
||||
});
|
||||
|
||||
// Return aggregate cleanup
|
||||
return () => {
|
||||
unsubscribeTokenRefresh();
|
||||
unsubscribeForeground();
|
||||
unsubscribeBackgroundOpen();
|
||||
};
|
||||
}
|
||||
|
||||
export const NotificationService = {
|
||||
requestPermission,
|
||||
createDefaultChannel,
|
||||
getFCMToken,
|
||||
onTokenRefresh,
|
||||
displayNotification,
|
||||
onForegroundMessage,
|
||||
registerBackgroundHandler,
|
||||
onNotificationOpenedApp,
|
||||
getInitialNotification,
|
||||
setBadgeCount,
|
||||
clearBadge,
|
||||
initialize,
|
||||
};
|
||||
@ -24,7 +24,7 @@ const initialState: LoginState = {
|
||||
loginMessage: null,
|
||||
};
|
||||
|
||||
export const reducers = createReducer(initialState, builder => {
|
||||
export const authReducer = createReducer(initialState, builder => {
|
||||
builder
|
||||
.addCase(login.pending, acc => {
|
||||
acc.loginLoading = true;
|
||||
@ -49,4 +49,4 @@ export const reducers = createReducer(initialState, builder => {
|
||||
});
|
||||
});
|
||||
|
||||
export default reducers;
|
||||
export default authReducer;
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { createAsyncThunk } from '@reduxjs/toolkit';
|
||||
import { createAction, createAsyncThunk } from '@reduxjs/toolkit';
|
||||
import { LoginRequest, LoginResponse } from '@interfaces';
|
||||
import { loginApi } from '@api';
|
||||
|
||||
@ -12,3 +12,5 @@ export const login = createAsyncThunk<LoginResponse, LoginRequest>(
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
export const logout = createAction('USER_LOGOUT');
|
||||
|
||||
2
app/store/commonReducers/countrylist/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export * from './thunk';
|
||||
export * from './reducers';
|
||||
37
app/store/commonReducers/countrylist/reducers.ts
Normal file
@ -0,0 +1,37 @@
|
||||
import { createReducer } from '@reduxjs/toolkit';
|
||||
import { getCountryList } from './thunk';
|
||||
import { CountryListItem } from '@interfaces';
|
||||
|
||||
export interface CountryListState {
|
||||
items: CountryListItem[];
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
const initialState: CountryListState = {
|
||||
items: [],
|
||||
loading: false,
|
||||
error: null,
|
||||
};
|
||||
|
||||
export const countryListReducer = createReducer(initialState, builder => {
|
||||
builder
|
||||
.addCase(getCountryList.pending, acc => {
|
||||
acc.loading = true;
|
||||
acc.error = null;
|
||||
})
|
||||
.addCase(getCountryList.fulfilled, (acc, action) => {
|
||||
acc.loading = false;
|
||||
acc.items = action.payload;
|
||||
acc.error = null;
|
||||
})
|
||||
.addCase(getCountryList.rejected, (acc, action) => {
|
||||
acc.loading = false;
|
||||
acc.error =
|
||||
(action.payload as string) ??
|
||||
action.error.message ??
|
||||
'Failed to fetch country list';
|
||||
});
|
||||
});
|
||||
|
||||
export default countryListReducer;
|
||||
14
app/store/commonReducers/countrylist/thunk.ts
Normal file
@ -0,0 +1,14 @@
|
||||
import { createAsyncThunk } from '@reduxjs/toolkit';
|
||||
import { getCountryListApi } from '@api';
|
||||
import { CountryListItem } from '@interfaces';
|
||||
|
||||
export const getCountryList = createAsyncThunk<CountryListItem[]>(
|
||||
'countrylist/getCountryList',
|
||||
async (_, { rejectWithValue }) => {
|
||||
try {
|
||||
return await getCountryListApi();
|
||||
} catch (error: any) {
|
||||
return rejectWithValue(error.message || 'Failed to fetch country list');
|
||||
}
|
||||
},
|
||||
);
|
||||
@ -1 +1,4 @@
|
||||
export * from './auth';
|
||||
export * from './statuslist';
|
||||
export * from './sourcelist';
|
||||
export * from './countrylist';
|
||||
|
||||
2
app/store/commonReducers/sourcelist/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export * from './thunk';
|
||||
export * from './reducers';
|
||||
37
app/store/commonReducers/sourcelist/reducers.ts
Normal file
@ -0,0 +1,37 @@
|
||||
import { createReducer } from '@reduxjs/toolkit';
|
||||
import { getSourceList } from './thunk';
|
||||
import { SourceListItem } from '@interfaces';
|
||||
|
||||
export interface SourceListState {
|
||||
items: SourceListItem[];
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
const initialState: SourceListState = {
|
||||
items: [],
|
||||
loading: false,
|
||||
error: null,
|
||||
};
|
||||
|
||||
export const sourceListReducer = createReducer(initialState, builder => {
|
||||
builder
|
||||
.addCase(getSourceList.pending, acc => {
|
||||
acc.loading = true;
|
||||
acc.error = null;
|
||||
})
|
||||
.addCase(getSourceList.fulfilled, (acc, action) => {
|
||||
acc.loading = false;
|
||||
acc.items = action.payload;
|
||||
acc.error = null;
|
||||
})
|
||||
.addCase(getSourceList.rejected, (acc, action) => {
|
||||
acc.loading = false;
|
||||
acc.error =
|
||||
(action.payload as string) ??
|
||||
action.error.message ??
|
||||
'Failed to fetch source list';
|
||||
});
|
||||
});
|
||||
|
||||
export default sourceListReducer;
|
||||
14
app/store/commonReducers/sourcelist/thunk.ts
Normal file
@ -0,0 +1,14 @@
|
||||
import { createAsyncThunk } from '@reduxjs/toolkit';
|
||||
import { getSourceListApi } from '@api';
|
||||
import { SourceListItem } from '@interfaces';
|
||||
|
||||
export const getSourceList = createAsyncThunk<SourceListItem[]>(
|
||||
'sourcelist/getSourceList',
|
||||
async (_, { rejectWithValue }) => {
|
||||
try {
|
||||
return await getSourceListApi();
|
||||
} catch (error: any) {
|
||||
return rejectWithValue(error.message || 'Failed to fetch source list');
|
||||
}
|
||||
},
|
||||
);
|
||||
2
app/store/commonReducers/statuslist/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export * from './thunk';
|
||||
export * from './reducers';
|
||||
37
app/store/commonReducers/statuslist/reducers.ts
Normal file
@ -0,0 +1,37 @@
|
||||
import { createReducer } from '@reduxjs/toolkit';
|
||||
import { getStatusList } from './thunk';
|
||||
import { StatusListItem } from '@interfaces';
|
||||
|
||||
export interface StatusListState {
|
||||
items: StatusListItem[];
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
const initialState: StatusListState = {
|
||||
items: [],
|
||||
loading: false,
|
||||
error: null,
|
||||
};
|
||||
|
||||
export const statusListReducer = createReducer(initialState, builder => {
|
||||
builder
|
||||
.addCase(getStatusList.pending, acc => {
|
||||
acc.loading = true;
|
||||
acc.error = null;
|
||||
})
|
||||
.addCase(getStatusList.fulfilled, (acc, action) => {
|
||||
acc.loading = false;
|
||||
acc.items = action.payload;
|
||||
acc.error = null;
|
||||
})
|
||||
.addCase(getStatusList.rejected, (acc, action) => {
|
||||
acc.loading = false;
|
||||
acc.error =
|
||||
(action.payload as string) ??
|
||||
action.error.message ??
|
||||
'Failed to fetch status list';
|
||||
});
|
||||
});
|
||||
|
||||
export default statusListReducer;
|
||||
14
app/store/commonReducers/statuslist/thunk.ts
Normal file
@ -0,0 +1,14 @@
|
||||
import { createAsyncThunk } from '@reduxjs/toolkit';
|
||||
import { getStatusListApi } from '@api';
|
||||
import { StatusListItem } from '@interfaces';
|
||||
|
||||
export const getStatusList = createAsyncThunk<StatusListItem[]>(
|
||||
'statuslist/getStatusList',
|
||||
async (_, { rejectWithValue }) => {
|
||||
try {
|
||||
return await getStatusListApi();
|
||||
} catch (error: any) {
|
||||
return rejectWithValue(error.message || 'Failed to fetch status list');
|
||||
}
|
||||
},
|
||||
);
|
||||
@ -1,13 +1,26 @@
|
||||
import { combineReducers } from '@reduxjs/toolkit';
|
||||
import authReducer from './commonReducers/auth/reducers';
|
||||
import statusListReducer from './commonReducers/statuslist/reducers';
|
||||
import sourceListReducer from './commonReducers/sourcelist/reducers';
|
||||
import countryListReducer from './commonReducers/countrylist/reducers';
|
||||
import leadsReducer from '../features/leads/reducers';
|
||||
import leadDetailsReducer from '../features/leadDetails/reducers';
|
||||
|
||||
const rootReducer = combineReducers({
|
||||
const appReducer = combineReducers({
|
||||
auth: authReducer,
|
||||
statusList: statusListReducer,
|
||||
sourceList: sourceListReducer,
|
||||
countryList: countryListReducer,
|
||||
leads: leadsReducer,
|
||||
leadDetails: leadDetailsReducer,
|
||||
});
|
||||
|
||||
export type RootState = ReturnType<typeof rootReducer>;
|
||||
const rootReducer = (state: any, action: any) => {
|
||||
if (action.type === 'USER_LOGOUT') {
|
||||
state = undefined;
|
||||
}
|
||||
return appReducer(state, action);
|
||||
};
|
||||
|
||||
export type RootState = ReturnType<typeof appReducer>;
|
||||
export default rootReducer;
|
||||
|
||||
@ -1,39 +1,44 @@
|
||||
// Palette derived from the app background (blue/navy/cyan)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Design System Palette — Modern CRM (Indigo-Violet base)
|
||||
// Light: Clean whites with indigo accents, warm card surfaces
|
||||
// Dark: Rich deep slate/charcoal with electric indigo/violet accents
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
export const colors = {
|
||||
// Brand
|
||||
primary: '#1565C0', // deep blue (dark bg accent / light primary)
|
||||
primaryLight: '#1E90FF', // bright sky blue
|
||||
accent: '#4FC3F7', // cyan highlight
|
||||
// ── Brand tokens ────────────────────────────────────────────────────────────
|
||||
primary: '#5B4CF5', // electric indigo — primary actions
|
||||
primaryLight: '#7C6FF7', // lighter indigo for hover / tint
|
||||
accent: '#8B5CF6', // violet — gradient end, highlights
|
||||
accentAlt: '#06B6D4', // cyan — contrast accent (badges, chips)
|
||||
|
||||
// Light theme
|
||||
// ── Light theme ─────────────────────────────────────────────────────────────
|
||||
light: {
|
||||
background: '#F0F6FF',
|
||||
surface: '#FFFFFF',
|
||||
border: '#BBDEFB',
|
||||
text: '#0A1A6B',
|
||||
textSecondary: '#1E3A8A',
|
||||
textMuted: '#5C7BA8',
|
||||
icon: '#1565C0',
|
||||
background: '#F4F6FB', // soft cool white-grey — avoids pure white glare
|
||||
surface: '#FFFFFF', // pure white surfaces (search, buttons)
|
||||
card: '#FFFFFF', // card bg — clean white with shadow
|
||||
border: '#E4E8F5', // subtle lavender-grey border
|
||||
text: '#1A1D3A', // near-black with slight blue tint — excellent contrast
|
||||
textSecondary: '#4B5280', // medium slate — readable secondary text
|
||||
textMuted: '#9399BB', // muted lavender — placeholders, dates
|
||||
icon: '#5B4CF5', // brand indigo icons
|
||||
tabBar: '#FFFFFF',
|
||||
header: '#FFFFFF',
|
||||
drawerBg: '#FFFFFF',
|
||||
drawerHeader: '#0A237A',
|
||||
card: '#FFFFFF',
|
||||
drawerHeader: '#1A1D3A',
|
||||
},
|
||||
|
||||
// Dark theme
|
||||
// ── Dark theme ──────────────────────────────────────────────────────────────
|
||||
dark: {
|
||||
background: '#07112A',
|
||||
surface: '#0D1F4A',
|
||||
border: '#1A3575',
|
||||
text: '#E3F0FF',
|
||||
textSecondary: '#A8C8F5',
|
||||
textMuted: '#5C7BA8',
|
||||
icon: '#4FC3F7',
|
||||
tabBar: '#0D1F4A',
|
||||
header: '#0D1F4A',
|
||||
drawerBg: '#0A1A6B',
|
||||
drawerHeader: '#07112A',
|
||||
card: '#0D1F4A',
|
||||
background: '#0F1022', // very deep blue-black — premium feel
|
||||
surface: '#1A1D3A', // dark navy-indigo — elevated surfaces
|
||||
card: '#1E2140', // slightly lighter than surface for cards
|
||||
border: '#2D3260', // muted indigo border — visible but subtle
|
||||
text: '#EEF0FF', // near-white with slight violet tint
|
||||
textSecondary: '#A5AACF', // soft lavender — readable secondary
|
||||
textMuted: '#5C6290', // deep muted indigo — placeholders, dates
|
||||
icon: '#7C6FF7', // light indigo icons
|
||||
tabBar: '#1A1D3A',
|
||||
header: '#1A1D3A',
|
||||
drawerBg: '#141630',
|
||||
drawerHeader: '#0F1022',
|
||||
},
|
||||
} as const;
|
||||
|
||||
@ -1,2 +1,3 @@
|
||||
export const backGroundImage = require('./background.png')
|
||||
export const logo = require('./logo.png')
|
||||
|
||||
|
||||
BIN
app/utils/assets/logo.png
Normal file
|
After Width: | Height: | Size: 37 KiB |
@ -51,3 +51,19 @@ export const getInitials = (name?: string | null): string => {
|
||||
|
||||
return name.substring(0, 2).toUpperCase();
|
||||
};
|
||||
|
||||
export const toRgba = (hex: string, alpha: number): string => {
|
||||
const h = hex.replace('#', '');
|
||||
const r = parseInt(h.slice(0, 2), 16);
|
||||
const g = parseInt(h.slice(2, 4), 16);
|
||||
const b = parseInt(h.slice(4, 6), 16);
|
||||
return `rgba(${r},${g},${b},${alpha})`;
|
||||
};
|
||||
|
||||
export const formatDate = (raw: string): string => {
|
||||
if (!raw) return '';
|
||||
const d = new Date(raw);
|
||||
return isNaN(d.getTime())
|
||||
? raw
|
||||
: d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
|
||||
};
|
||||
|
||||
BIN
assets/bootsplash/logo.png
Normal file
|
After Width: | Height: | Size: 2.8 KiB |
BIN
assets/bootsplash/logo@1,5x.png
Normal file
|
After Width: | Height: | Size: 4.0 KiB |
BIN
assets/bootsplash/logo@2x.png
Normal file
|
After Width: | Height: | Size: 5.6 KiB |
BIN
assets/bootsplash/logo@3x.png
Normal file
|
After Width: | Height: | Size: 9.7 KiB |
BIN
assets/bootsplash/logo@4x.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
7
assets/bootsplash/manifest.json
Normal file
@ -0,0 +1,7 @@
|
||||
{
|
||||
"background": "#ffffff",
|
||||
"logo": {
|
||||
"width": 100,
|
||||
"height": 100
|
||||
}
|
||||
}
|
||||
BIN
assets/logo.png
Normal file
|
After Width: | Height: | Size: 37 KiB |
3
index.js
@ -1,9 +1,12 @@
|
||||
import { AppRegistry } from 'react-native';
|
||||
import App from './app/App';
|
||||
import { name as appName } from './app.json';
|
||||
import { NotificationService } from '@services';
|
||||
|
||||
if (__DEV__) {
|
||||
require('./ReactotronConfig');
|
||||
}
|
||||
|
||||
NotificationService.registerBackgroundHandler();
|
||||
|
||||
AppRegistry.registerComponent(appName, () => App);
|
||||
|
||||
@ -11,6 +11,8 @@
|
||||
13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 13B07FB51A68108700A75B9A /* Images.xcassets */; };
|
||||
761780ED2CA45674006654EE /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 761780EC2CA45674006654EE /* AppDelegate.swift */; };
|
||||
81AB9BB82411601600AC10FF /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 81AB9BB72411601600AC10FF /* LaunchScreen.storyboard */; };
|
||||
79E049D907A74F1AB0714099 /* BootSplash.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 688D1DB853AD445C91817BC9 /* BootSplash.storyboard */; };
|
||||
1E6477E483F84C6495D52EC7 /* Colors.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A57FEAAA1E5B410F8EEA9BA8 /* Colors.xcassets */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
@ -24,6 +26,8 @@
|
||||
761780EC2CA45674006654EE /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = AppDelegate.swift; path = convex_CRM/AppDelegate.swift; sourceTree = "<group>"; };
|
||||
81AB9BB72411601600AC10FF /* LaunchScreen.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; name = LaunchScreen.storyboard; path = convex_CRM/LaunchScreen.storyboard; sourceTree = "<group>"; };
|
||||
ED297162215061F000B7C4FE /* JavaScriptCore.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = JavaScriptCore.framework; path = System/Library/Frameworks/JavaScriptCore.framework; sourceTree = SDKROOT; };
|
||||
688D1DB853AD445C91817BC9 /* BootSplash.storyboard */ = {isa = PBXFileReference; name = "BootSplash.storyboard"; path = "convex_CRM/BootSplash.storyboard"; sourceTree = "<group>"; fileEncoding = undefined; lastKnownFileType = unknown; explicitFileType = undefined; includeInIndex = 0; };
|
||||
A57FEAAA1E5B410F8EEA9BA8 /* Colors.xcassets */ = {isa = PBXFileReference; name = "Colors.xcassets"; path = "convex_CRM/Colors.xcassets"; sourceTree = "<group>"; fileEncoding = undefined; lastKnownFileType = folder.assetcatalog; explicitFileType = undefined; includeInIndex = 0; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
@ -46,6 +50,8 @@
|
||||
13B07FB61A68108700A75B9A /* Info.plist */,
|
||||
81AB9BB72411601600AC10FF /* LaunchScreen.storyboard */,
|
||||
13B07FB81A68108700A75B9A /* PrivacyInfo.xcprivacy */,
|
||||
688D1DB853AD445C91817BC9 /* BootSplash.storyboard */,
|
||||
A57FEAAA1E5B410F8EEA9BA8 /* Colors.xcassets */,
|
||||
);
|
||||
name = convex_CRM;
|
||||
sourceTree = "<group>";
|
||||
@ -159,6 +165,8 @@
|
||||
files = (
|
||||
81AB9BB82411601600AC10FF /* LaunchScreen.storyboard in Resources */,
|
||||
13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */,
|
||||
79E049D907A74F1AB0714099 /* BootSplash.storyboard in Resources */,
|
||||
1E6477E483F84C6495D52EC7 /* Colors.xcassets in Resources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
|
||||
46
ios/convex_CRM/BootSplash.storyboard
Normal file
@ -0,0 +1,46 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="21701" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" launchScreen="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES" initialViewController="01J-lp-oVM">
|
||||
<device id="retina4_7" orientation="portrait" appearance="light"/>
|
||||
<dependencies>
|
||||
<deployment identifier="iOS"/>
|
||||
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="21678"/>
|
||||
<capability name="Named colors" minToolsVersion="9.0"/>
|
||||
<capability name="Safe area layout guides" minToolsVersion="9.0"/>
|
||||
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
|
||||
</dependencies>
|
||||
<scenes>
|
||||
<!--View Controller-->
|
||||
<scene sceneID="EHf-IW-A2E">
|
||||
<objects>
|
||||
<viewController modalTransitionStyle="crossDissolve" id="01J-lp-oVM" sceneMemberID="viewController">
|
||||
<view key="view" autoresizesSubviews="NO" contentMode="scaleToFill" id="Ze5-6b-2t3">
|
||||
<rect key="frame" x="0.0" y="0.0" width="375" height="667"/>
|
||||
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
|
||||
<subviews>
|
||||
<imageView autoresizesSubviews="NO" clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleAspectFit" image="BootSplashLogo-15a9bf" translatesAutoresizingMaskIntoConstraints="NO" id="3lX-Ut-9ad">
|
||||
<rect key="frame" x="137.5" y="283.5" width="100" height="100"/>
|
||||
<accessibility key="accessibilityConfiguration">
|
||||
<accessibilityTraits key="traits" image="YES" notEnabled="YES"/>
|
||||
</accessibility>
|
||||
</imageView>
|
||||
</subviews>
|
||||
<viewLayoutGuide key="safeArea" id="Bcu-3y-fUS"/>
|
||||
<color key="backgroundColor" name="BootSplashBackground-15a9bf"/>
|
||||
<constraints>
|
||||
<constraint firstItem="3lX-Ut-9ad" firstAttribute="centerX" secondItem="Ze5-6b-2t3" secondAttribute="centerX" id="Fh9-Fy-1nT"/>
|
||||
<constraint firstItem="3lX-Ut-9ad" firstAttribute="centerY" secondItem="Ze5-6b-2t3" secondAttribute="centerY" id="nvB-Ic-PnI"/>
|
||||
</constraints>
|
||||
</view>
|
||||
</viewController>
|
||||
<placeholder placeholderIdentifier="IBFirstResponder" id="iYj-Kq-Ea1" userLabel="First Responder" sceneMemberID="firstResponder"/>
|
||||
</objects>
|
||||
<point key="canvasLocation" x="0.0" y="0.0"/>
|
||||
</scene>
|
||||
</scenes>
|
||||
<resources>
|
||||
<image name="BootSplashLogo-15a9bf" width="100" height="100"/>
|
||||
<namedColor name="BootSplashBackground-15a9bf">
|
||||
<color red="1.00000000000000" green="1.00000000000000" blue="1.00000000000000" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
|
||||
</namedColor>
|
||||
</resources>
|
||||
</document>
|
||||
@ -0,0 +1,20 @@
|
||||
{
|
||||
"colors": [
|
||||
{
|
||||
"idiom": "universal",
|
||||
"color": {
|
||||
"color-space": "srgb",
|
||||
"components": {
|
||||
"blue": "1.00000000000000",
|
||||
"green": "1.00000000000000",
|
||||
"red": "1.00000000000000",
|
||||
"alpha": "1.000"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"info": {
|
||||
"author": "xcode",
|
||||
"version": 1
|
||||
}
|
||||
}
|
||||
23
ios/convex_CRM/Images.xcassets/BootSplashLogo-15a9bf.imageset/Contents.json
vendored
Normal file
@ -0,0 +1,23 @@
|
||||
{
|
||||
"images": [
|
||||
{
|
||||
"idiom": "universal",
|
||||
"filename": "logo-15a9bf.png",
|
||||
"scale": "1x"
|
||||
},
|
||||
{
|
||||
"idiom": "universal",
|
||||
"filename": "logo-15a9bf@2x.png",
|
||||
"scale": "2x"
|
||||
},
|
||||
{
|
||||
"idiom": "universal",
|
||||
"filename": "logo-15a9bf@3x.png",
|
||||
"scale": "3x"
|
||||
}
|
||||
],
|
||||
"info": {
|
||||
"author": "xcode",
|
||||
"version": 1
|
||||
}
|
||||
}
|
||||
BIN
ios/convex_CRM/Images.xcassets/BootSplashLogo-15a9bf.imageset/logo-15a9bf.png
vendored
Normal file
|
After Width: | Height: | Size: 2.8 KiB |