feat(app): add splash and apis

This commit is contained in:
uttam 2026-07-22 13:33:19 +05:30
parent 395bed3a9c
commit 168c9fd39c
105 changed files with 3400 additions and 771 deletions

View File

@ -1,6 +1,7 @@
apply plugin: "com.android.application" apply plugin: "com.android.application"
apply plugin: "org.jetbrains.kotlin.android" apply plugin: "org.jetbrains.kotlin.android"
apply plugin: "com.facebook.react" apply plugin: "com.facebook.react"
apply plugin: 'com.google.gms.google-services'
apply from: project(':react-native-config').projectDir.getPath() + "/dotenv.gradle" apply from: project(':react-native-config').projectDir.getPath() + "/dotenv.gradle"
apply from: "../../node_modules/react-native-vector-icons/fonts.gradle" apply from: "../../node_modules/react-native-vector-icons/fonts.gradle"
@ -81,14 +82,14 @@ android {
buildToolsVersion rootProject.ext.buildToolsVersion buildToolsVersion rootProject.ext.buildToolsVersion
compileSdk rootProject.ext.compileSdkVersion compileSdk rootProject.ext.compileSdkVersion
namespace "com.convex_crm" namespace "app.convexsol.convexsolcrm"
defaultConfig { defaultConfig {
applicationId "com.convex_crm" applicationId "app.convexsol.convexsolcrm"
minSdkVersion rootProject.ext.minSdkVersion minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 1 versionCode 1
versionName "1.0" versionName "1.0"
resValue "string", "build_config_package", "com.convex_crm" resValue "string", "build_config_package", "app.convexsol.convexsolcrm"
} }
signingConfigs { signingConfigs {
debug { debug {

View 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"
}

View File

@ -1,6 +1,6 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<application <application
android:name=".MainApplication" android:name=".MainApplication"
@ -17,7 +17,8 @@
android:configChanges="keyboard|keyboardHidden|orientation|screenLayout|screenSize|smallestScreenSize|uiMode" android:configChanges="keyboard|keyboardHidden|orientation|screenLayout|screenSize|smallestScreenSize|uiMode"
android:launchMode="singleTask" android:launchMode="singleTask"
android:windowSoftInputMode="adjustResize" android:windowSoftInputMode="adjustResize"
android:exported="true"> android:exported="true"
android:theme="@style/BootTheme">
<intent-filter> <intent-filter>
<action android:name="android.intent.action.MAIN" /> <action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.LAUNCHER" />

View File

@ -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.ReactActivity
import com.facebook.react.ReactActivityDelegate import com.facebook.react.ReactActivityDelegate
import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint.fabricEnabled import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint.fabricEnabled
import com.facebook.react.defaults.DefaultReactActivityDelegate import com.facebook.react.defaults.DefaultReactActivityDelegate
import com.zoontek.rnbootsplash.RNBootSplash
class MainActivity : ReactActivity() { 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 * Returns the name of the main component registered from JavaScript. This is used to schedule
* rendering of the component. * rendering of the component.

View File

@ -1,4 +1,4 @@
package com.convex_crm package app.convexsol.convexsolcrm
import android.app.Application import android.app.Application
import com.facebook.react.PackageList import com.facebook.react.PackageList

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

View 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>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.0 KiB

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.9 KiB

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.5 KiB

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.9 KiB

After

Width:  |  Height:  |  Size: 6.8 KiB

View 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>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.3 KiB

After

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.0 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

View File

@ -0,0 +1,3 @@
<resources>
<color name="bootsplash_background">#ffffff</color>
</resources>

View File

@ -1,9 +1,12 @@
<resources> <resources>
<!-- Base application theme. -->
<style name="AppTheme" parent="Theme.AppCompat.DayNight.NoActionBar"> <style name="AppTheme" parent="Theme.AppCompat.DayNight.NoActionBar">
<!-- Customize your theme here. --> <!-- Customize your theme here. -->
<item name="android:editTextBackground">@drawable/rn_edit_text_material</item> <item name="android:editTextBackground">@drawable/rn_edit_text_material</item>
</style> </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> </resources>

View File

@ -15,6 +15,7 @@ buildscript {
classpath("com.android.tools.build:gradle") classpath("com.android.tools.build:gradle")
classpath("com.facebook.react:react-native-gradle-plugin") classpath("com.facebook.react:react-native-gradle-plugin")
classpath("org.jetbrains.kotlin:kotlin-gradle-plugin") classpath("org.jetbrains.kotlin:kotlin-gradle-plugin")
classpath ("com.google.gms:google-services:4.3.15")
} }
} }

View File

@ -1,11 +1,13 @@
import React, { useEffect } from 'react'; 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 { SafeAreaProvider } from 'react-native-safe-area-context';
import { RootNavigator } from './navigation/rootNavigator'; import { RootNavigator } from './navigation/rootNavigator';
import { ThemeProvider, useTheme } from './theme'; import { ThemeProvider, useTheme } from './theme';
import { Provider } from 'react-redux'; 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 { PersistGate } from 'redux-persist/integration/react';
import { NotificationService } from '@services';
import BootSplash from 'react-native-bootsplash';
const ThemedStatusBar = () => { const ThemedStatusBar = () => {
const { theme: colors, isDark } = useTheme(); 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 = () => { 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 ( return (
<Provider store={store}> <Provider store={store}>
<PersistGate loading={null} persistor={persistor}> <PersistGate loading={null} persistor={persistor}>
<SafeAreaProvider> <SafeAreaProvider>
<ThemeProvider> <ThemeProvider>
<ThemedStatusBar /> <ThemedStatusBar />
<AppInit />
<RootNavigator /> <RootNavigator />
</ThemeProvider> </ThemeProvider>
</SafeAreaProvider> </SafeAreaProvider>

View File

@ -1,3 +1,4 @@
export * from './authApi'; export * from './authApi';
export * from './leadsApi'; export * from './leadsApi';
export * from './leadDetailsApi'; export * from './leadDetailsApi';
export * from './listApi';

View File

@ -1,4 +1,4 @@
import { LeadItem } from '@interfaces'; import { LeadItem, LeadCountItem } from '@interfaces';
import { api } from '@utils'; import { api } from '@utils';
export const getLeadsApi = async ( export const getLeadsApi = async (
@ -8,3 +8,10 @@ export const getLeadsApi = async (
const url = `/api/leads/${leadId}/${staffId}`; const url = `/api/leads/${leadId}/${staffId}`;
return await api.get<LeadItem[]>(url); 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
View 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);
};

View File

@ -5,20 +5,22 @@ export const getStyles = (colors: ThemeColors) =>
StyleSheet.create({ StyleSheet.create({
card: { card: {
backgroundColor: colors.card, backgroundColor: colors.card,
borderRadius: 12, borderRadius: 18,
padding: 16, padding: 16,
marginBottom: 12, marginBottom: 14,
shadowColor: '#000', borderWidth: 1,
shadowOffset: { width: 0, height: 2 }, borderColor: colors.border,
shadowOpacity: 0.05, shadowColor: '#5B4CF5',
shadowRadius: 4, shadowOffset: { width: 0, height: 4 },
elevation: 2, shadowOpacity: 0.06,
shadowRadius: 12,
elevation: 3,
}, },
header: { header: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
justifyContent: 'space-between', justifyContent: 'space-between',
marginBottom: 12, marginBottom: 14,
}, },
leftSection: { leftSection: {
flexDirection: 'row', flexDirection: 'row',
@ -26,19 +28,21 @@ export const getStyles = (colors: ThemeColors) =>
flex: 1, flex: 1,
}, },
iconContainer: { iconContainer: {
width: 40, width: 42,
height: 40, height: 42,
borderRadius: 20, borderRadius: 12,
backgroundColor: colors.surface, backgroundColor: colors.surface,
justifyContent: 'center', justifyContent: 'center',
alignItems: 'center', alignItems: 'center',
marginRight: 12, marginRight: 12,
borderWidth: 1,
borderColor: colors.border,
}, },
titleSection: { titleSection: {
flex: 1, flex: 1,
}, },
title: { title: {
fontSize: 16, fontSize: 15,
fontWeight: '700', fontWeight: '700',
color: colors.text, color: colors.text,
}, },
@ -49,31 +53,31 @@ export const getStyles = (colors: ThemeColors) =>
}, },
actionsRow: { actionsRow: {
flexDirection: 'row', flexDirection: 'row',
gap: 8, gap: 10,
}, },
actionButton: { actionBtn: {
flex: 1, flex: 1,
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
gap: 6,
paddingVertical: 10, paddingVertical: 10,
paddingHorizontal: 16, paddingHorizontal: 14,
borderRadius: 8, borderRadius: 12,
backgroundColor: colors.surface, backgroundColor: colors.surface,
borderWidth: 1, borderWidth: 1,
borderColor: colors.border, borderColor: colors.border,
}, },
addButton: { addBtn: {
backgroundColor: colors.icon, backgroundColor: colors.icon,
borderColor: colors.icon, borderColor: colors.icon,
}, },
actionButtonText: { actionText: {
fontSize: 14, fontSize: 13,
fontWeight: '600', fontWeight: '600',
color: colors.text, color: colors.text,
marginLeft: 6,
}, },
addButtonText: { addText: {
color: '#FFFFFF', color: '#FFFFFF',
}, },
}); });

View File

@ -17,11 +17,10 @@ export const ActionCard: React.FC<ActionCardProps> = ({
return ( return (
<View style={styles.card}> <View style={styles.card}>
{/* Header with Icon and Title */}
<View style={styles.header}> <View style={styles.header}>
<View style={styles.leftSection}> <View style={styles.leftSection}>
<View style={styles.iconContainer}> <View style={styles.iconContainer}>
<Icon name={icon} size={22} color={colors.icon} /> <Icon name={icon} size={20} color={colors.icon} />
</View> </View>
<View style={styles.titleSection}> <View style={styles.titleSection}>
<Text style={styles.title}>{title}</Text> <Text style={styles.title}>{title}</Text>
@ -34,24 +33,21 @@ export const ActionCard: React.FC<ActionCardProps> = ({
</View> </View>
</View> </View>
{/* Action Buttons */}
<View style={styles.actionsRow}> <View style={styles.actionsRow}>
<TouchableOpacity <TouchableOpacity
style={[styles.actionButton, styles.addButton]} style={[styles.actionBtn, styles.addBtn]}
onPress={onAddPress} onPress={onAddPress}
activeOpacity={0.7}> activeOpacity={0.75}>
<Icon name="add-circle-outline" size={18} color="#FFFFFF" /> <Icon name="add-circle-outline" size={16} color="#FFFFFF" />
<Text style={[styles.actionButtonText, styles.addButtonText]}> <Text style={[styles.actionText, styles.addText]}>Add</Text>
Add
</Text>
</TouchableOpacity> </TouchableOpacity>
<TouchableOpacity <TouchableOpacity
style={styles.actionButton} style={styles.actionBtn}
onPress={onViewAllPress} onPress={onViewAllPress}
activeOpacity={0.7}> activeOpacity={0.75}>
<Icon name="eye-outline" size={18} color={colors.icon} /> <Icon name="eye-outline" size={16} color={colors.icon} />
<Text style={styles.actionButtonText}>View All</Text> <Text style={styles.actionText}>View All</Text>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
</View> </View>

View File

@ -1,10 +1,15 @@
import { TextInputProps } from 'react-native'; import { TextInputProps, StyleProp, ViewStyle, TextStyle } from 'react-native';
export interface FormInputProps extends Omit<TextInputProps, 'style'> { export interface FormInputProps extends Omit<TextInputProps, 'style'> {
label: string; label?: string;
value: string; value: string;
onChangeText: (text: string) => void; onChangeText: (text: string) => void;
placeholder?: string; placeholder?: string;
required?: boolean; required?: boolean;
multiline?: boolean; multiline?: boolean;
leftIcon?: React.ReactNode;
rightIcon?: React.ReactNode;
containerStyle?: StyleProp<ViewStyle>;
inputContainerStyle?: StyleProp<ViewStyle>;
inputStyle?: StyleProp<TextStyle>;
} }

View File

@ -7,21 +7,38 @@ export const getStyles = (colors: ThemeColors) =>
marginBottom: 16, marginBottom: 16,
}, },
label: { label: {
fontSize: 14, fontSize: 13,
fontWeight: '600', fontWeight: '600',
color: colors.text, color: colors.textSecondary,
marginBottom: 8, marginBottom: 8,
textTransform: 'uppercase',
letterSpacing: 0.5,
}, },
required: { required: {
color: '#EF4444', color: '#EF4444',
marginLeft: 2, marginLeft: 2,
}, },
input: { inputWrapper: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: colors.surface, backgroundColor: colors.surface,
borderWidth: 1, borderWidth: 1,
borderColor: colors.border, borderColor: colors.border,
borderRadius: 10, borderRadius: 12,
paddingHorizontal: 16, paddingHorizontal: 12,
},
leftIconWrapper: {
marginRight: 8,
justifyContent: 'center',
alignItems: 'center',
},
rightIconWrapper: {
marginLeft: 8,
justifyContent: 'center',
alignItems: 'center',
},
input: {
flex: 1,
paddingVertical: 12, paddingVertical: 12,
fontSize: 15, fontSize: 15,
color: colors.text, color: colors.text,

View File

@ -12,19 +12,28 @@ export const FormInput: React.FC<FormInputProps> = ({
required = false, required = false,
multiline = false, multiline = false,
keyboardType = 'default', keyboardType = 'default',
leftIcon,
rightIcon,
containerStyle,
inputContainerStyle,
inputStyle,
...rest ...rest
}) => { }) => {
const { theme: colors } = useTheme(); const { theme: colors } = useTheme();
const styles = getStyles(colors); const styles = getStyles(colors);
return ( return (
<View style={styles.container}> <View style={[styles.container, containerStyle]}>
{label ? (
<Text style={styles.label}> <Text style={styles.label}>
{label} {label}
{required && <Text style={styles.required}>*</Text>} {required && <Text style={styles.required}>*</Text>}
</Text> </Text>
) : null}
<View style={[styles.inputWrapper, inputContainerStyle]}>
{leftIcon ? <View style={styles.leftIconWrapper}>{leftIcon}</View> : null}
<TextInput <TextInput
style={[styles.input, multiline && styles.multilineInput]} style={[styles.input, multiline && styles.multilineInput, inputStyle]}
value={value} value={value}
onChangeText={onChangeText} onChangeText={onChangeText}
placeholder={placeholder} placeholder={placeholder}
@ -34,6 +43,8 @@ export const FormInput: React.FC<FormInputProps> = ({
keyboardType={keyboardType} keyboardType={keyboardType}
{...rest} {...rest}
/> />
{rightIcon ? <View style={styles.rightIconWrapper}>{rightIcon}</View> : null}
</View>
</View> </View>
); );
}; };

View File

@ -8,3 +8,4 @@ export * from './formPicker';
export * from './addItemButton'; export * from './addItemButton';
export * from './actionButton'; export * from './actionButton';
export * from './checkboxWithLabel'; export * from './checkboxWithLabel';
export * from './statCard';

View File

@ -3,77 +3,98 @@ import { ThemeColors } from '../../theme';
export const getStyles = (colors: ThemeColors) => export const getStyles = (colors: ThemeColors) =>
StyleSheet.create({ StyleSheet.create({
container: { card: {
alignItems: 'center',
paddingVertical: 24,
paddingHorizontal: 16,
borderBottomWidth: 1,
borderBottomColor: colors.border,
backgroundColor: colors.card, backgroundColor: colors.card,
}, borderRadius: 20,
avatar: { padding: 20,
width: 80,
height: 80,
borderRadius: 40,
justifyContent: 'center',
alignItems: 'center', alignItems: 'center',
marginBottom: 16, 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: { avatarText: {
fontSize: 32, fontSize: 26,
fontWeight: '700', fontWeight: '800',
color: '#FFFFFF', color: '#FFFFFF',
letterSpacing: 0.5,
}, },
nameRow: { name: {
fontSize: 20,
fontWeight: '700',
color: colors.text,
textAlign: 'center',
marginBottom: 4,
},
companyRow: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
gap: 6,
marginBottom: 12, 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: { company: {
fontSize: 16, fontSize: 13,
color: colors.textSecondary, color: colors.textSecondary,
marginBottom: 12, fontWeight: '500',
textAlign: 'center',
}, },
contactRow: { statusBadge: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
marginBottom: 8, gap: 6,
paddingHorizontal: 16, paddingHorizontal: 12,
paddingVertical: 5,
borderRadius: 20,
marginBottom: 16,
}, },
contactLabel: { statusDot: {
fontSize: 14, 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', fontWeight: '600',
color: colors.text, color: colors.text,
marginRight: 8,
minWidth: 50,
},
contactText: {
fontSize: 14,
color: colors.textSecondary,
flex: 1,
},
iconButton: {
marginLeft: 8,
padding: 4,
}, },
}); });

View File

@ -4,6 +4,7 @@ import Icon from 'react-native-vector-icons/Ionicons';
import { useTheme } from '@theme'; import { useTheme } from '@theme';
import { getStyles } from './leadDetailHeader.styles'; import { getStyles } from './leadDetailHeader.styles';
import { LeadDetailHeaderProps } from './leadDetailHeader.props'; import { LeadDetailHeaderProps } from './leadDetailHeader.props';
import { getInitials, toRgba } from '@utils';
export const LeadDetailHeader: React.FC<LeadDetailHeaderProps> = ({ export const LeadDetailHeader: React.FC<LeadDetailHeaderProps> = ({
name, name,
@ -17,87 +18,62 @@ export const LeadDetailHeader: React.FC<LeadDetailHeaderProps> = ({
onEmailPress, onEmailPress,
onPhonePress, onPhonePress,
}) => { }) => {
const { theme: colors } = useTheme(); const { theme: colors, isDark } = useTheme();
const styles = getStyles(colors); const styles = getStyles(colors);
// Get initials from name const accent = statusColor || avatarColor || '#5B4CF5';
const getInitials = (name: string): string => { const badgeBg = toRgba(accent, isDark ? 0.20 : 0.12);
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();
};
return ( return (
<View style={styles.container}> <View style={styles.card}>
{/* Avatar */} <View style={[styles.avatar, { backgroundColor: accent }]}>
<View
style={[
styles.avatar,
{ backgroundColor: avatarColor || statusColor || '#1565C0' },
]}>
<Text style={styles.avatarText}>{getInitials(name)}</Text> <Text style={styles.avatarText}>{getInitials(name)}</Text>
</View> </View>
{/* Name and Status Row */} <Text style={styles.name} numberOfLines={1}>
<View style={styles.nameRow}> {name || 'No Name'}
<Text style={styles.name}>{name || 'No Name'}</Text> </Text>
{company && (
<View style={styles.companyRow}>
<Icon name="business-outline" size={13} color={colors.textMuted} />
<Text style={styles.company}>{company}</Text>
</View>
)}
<TouchableOpacity <TouchableOpacity
onPress={onStatusPress} onPress={onStatusPress}
activeOpacity={onStatusPress ? 0.7 : 1} activeOpacity={onStatusPress ? 0.7 : 1}
disabled={!onStatusPress}> disabled={!onStatusPress}>
<View <View style={[styles.statusBadge, { backgroundColor: badgeBg }]}>
style={[ <View style={[styles.statusDot, { backgroundColor: accent }]} />
styles.statusBadge, <Text style={[styles.statusText, { color: accent }]}>
{ backgroundColor: `${statusColor || '#6B7280'}20` },
]}>
<Text
style={[
styles.statusText,
{ color: statusColor || '#6B7280' },
]}>
{statusName || 'No Status'} {statusName || 'No Status'}
</Text> </Text>
</View> </View>
</TouchableOpacity> </TouchableOpacity>
</View>
{/* Company */} <View style={styles.quickActions}>
{company && <Text style={styles.company}>{company}</Text>}
{/* Email Row */}
{email && ( {email && (
<View style={styles.contactRow}>
<Text style={styles.contactLabel}>Email:</Text>
<Text style={styles.contactText}>{email}</Text>
{onEmailPress && (
<TouchableOpacity <TouchableOpacity
style={styles.iconButton} style={styles.actionBtn}
onPress={onEmailPress} onPress={onEmailPress}
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}> activeOpacity={0.7}>
<Icon name="send" size={18} color={colors.icon} /> <Icon name="mail-outline" size={16} color={colors.icon} />
<Text style={styles.actionBtnText}>Email</Text>
</TouchableOpacity> </TouchableOpacity>
)} )}
</View>
)}
{/* Phone Row */}
{phonenumber && ( {phonenumber && (
<View style={styles.contactRow}>
<Text style={styles.contactLabel}>Phone:</Text>
<Text style={styles.contactText}>{phonenumber}</Text>
{onPhonePress && (
<TouchableOpacity <TouchableOpacity
style={styles.iconButton} style={styles.actionBtn}
onPress={onPhonePress} onPress={onPhonePress}
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}> activeOpacity={0.7}>
<Icon name="call" size={18} color={colors.icon} /> <Icon name="call-outline" size={16} color={colors.icon} />
<Text style={styles.actionBtnText}>Call</Text>
</TouchableOpacity> </TouchableOpacity>
)} )}
</View> </View>
)}
</View> </View>
); );
}; };

View File

@ -3,72 +3,174 @@ import { ThemeColors } from '../../theme';
export const getStyles = (colors: ThemeColors) => export const getStyles = (colors: ThemeColors) =>
StyleSheet.create({ StyleSheet.create({
cardWrapper: {
marginBottom: 16,
borderRadius: 20,
shadowColor: '#5B4CF5',
shadowOffset: { width: 0, height: 6 },
shadowOpacity: 0.10,
shadowRadius: 18,
elevation: 6,
},
card: { card: {
backgroundColor: colors.card, backgroundColor: colors.card,
borderRadius: 12, borderRadius: 20,
padding: 16,
marginBottom: 12,
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', overflow: 'hidden',
shadowColor: '#000', borderWidth: 1,
shadowOffset: { width: 0, height: 2 }, borderColor: colors.border,
shadowOpacity: 0.05,
shadowRadius: 4,
elevation: 2,
}, },
avatarContainer: { leftStrip: {
width: 56, width: 4,
height: 56, borderTopLeftRadius: 20,
borderRadius: 28, 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', justifyContent: 'center',
alignItems: 'center', alignItems: 'center',
marginRight: 12, marginRight: 12,
}, },
avatarText: { avatarText: {
fontSize: 20, fontSize: 16,
fontWeight: '700', fontWeight: '800',
color: '#FFFFFF', color: '#FFFFFF',
letterSpacing: 0.5,
}, },
contentContainer: { meta: {
flex: 1, flex: 1,
}, minWidth: 0,
headerRow: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 8,
}, },
name: { name: {
fontSize: 16, fontSize: 15,
fontWeight: '700', fontWeight: '700',
color: colors.text, color: colors.text,
flex: 1, marginBottom: 3,
marginRight: 8, 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: { statusBadge: {
alignSelf: 'flex-start',
flexDirection: 'row',
alignItems: 'center',
gap: 5,
paddingHorizontal: 10, paddingHorizontal: 10,
paddingVertical: 4, paddingVertical: 4,
borderRadius: 12, borderRadius: 20,
flexShrink: 0, },
statusDot: {
width: 5,
height: 5,
borderRadius: 3,
}, },
statusText: { statusText: {
fontSize: 11, fontSize: 10,
fontWeight: '600', fontWeight: '700',
textTransform: 'capitalize', textTransform: 'uppercase',
letterSpacing: 0.7,
},
separator: {
height: StyleSheet.hairlineWidth,
backgroundColor: colors.border,
marginVertical: 11,
},
contacts: {
gap: 8,
}, },
contactRow: { contactRow: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
justifyContent: 'space-between', gap: 9,
marginBottom: 6, },
iconWrap: {
width: 28,
height: 28,
borderRadius: 9,
justifyContent: 'center',
alignItems: 'center',
}, },
contactText: { contactText: {
fontSize: 13, fontSize: 12,
color: colors.textSecondary, color: colors.textSecondary,
flex: 1, flex: 1,
marginRight: 8,
}, },
contactIconButton: { actionBtn: {
padding: 4, 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,
}, },
}); });

View File

@ -1,83 +1,111 @@
import React from 'react'; import React from 'react';
import { import { View, Text, TouchableOpacity } from 'react-native';
View,
Text,
TouchableOpacity,
} from 'react-native';
import Icon from 'react-native-vector-icons/Ionicons'; import Icon from 'react-native-vector-icons/Ionicons';
import { useTheme } from '@theme'; import { useTheme } from '@theme';
import { getStyles } from './leadItemCard.styles'; import { getStyles } from './leadItemCard.styles';
import { LeadItemCardProps } from './leadItemCard.props'; 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> = ({ export const LeadItemCard: React.FC<LeadItemCardProps> = ({ item, onPress }) => {
item, const { theme: colors, isDark } = useTheme();
onPress,
}) => {
const { theme: colors } = useTheme();
const styles = getStyles(colors); const styles = getStyles(colors);
const accent = item.color?.startsWith('#') ? item.color : '#5B4CF5';
const tint = (a: number) => toRgba(accent, isDark ? a + 0.08 : a);
return ( return (
<TouchableOpacity <TouchableOpacity style={styles.cardWrapper} onPress={onPress} activeOpacity={0.8}>
style={styles.card} <View style={styles.card}>
onPress={onPress} <View style={[styles.leftStrip, { backgroundColor: accent }]} />
activeOpacity={0.7}>
{/* Left side - Avatar/Initials */} <View style={styles.cardInner}>
<View style={[styles.avatarContainer, { backgroundColor: item.color || '#1565C0' }]}> <View style={styles.topSection}>
<View style={[styles.avatar, { backgroundColor: accent }]}>
<Text style={styles.avatarText}>{getInitials(item.name)}</Text> <Text style={styles.avatarText}>{getInitials(item.name)}</Text>
</View> </View>
{/* Right side - Content */} <View style={styles.meta}>
<View style={styles.contentContainer}>
{/* Name and Status Row */}
<View style={styles.headerRow}>
<Text style={styles.name} numberOfLines={1}> <Text style={styles.name} numberOfLines={1}>
{item.name || 'No Name'} {item.name || 'No Name'}
</Text> </Text>
<View
style={[ {(item.company || item.title) && (
styles.statusBadge, <View style={styles.companyRow}>
{ backgroundColor: `${item.color || '#6B7280'}20` }, {item.company && (
]}> <>
<Text <Icon name="business-outline" size={11} color={colors.textMuted} />
style={[ <Text style={styles.companyText} numberOfLines={1}>{item.company}</Text>
styles.statusText, </>
{ color: item.color || '#6B7280' }, )}
]}> {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'} {item.status_name || item.status || 'No Status'}
</Text> </Text>
</View> </View>
</View> </View>
</View>
{/* Email Row */} {(item.email || item.phonenumber) && (
{item.email ? ( <>
<View style={styles.separator} />
<View style={styles.contacts}>
{item.email && (
<View style={styles.contactRow}> <View style={styles.contactRow}>
<Text style={styles.contactText} numberOfLines={1}> <View style={[styles.iconWrap, { backgroundColor: tint(0.08) }]}>
{item.email} <Icon name="mail-outline" size={13} color={accent} />
</Text> </View>
<Text style={styles.contactText} numberOfLines={1}>{item.email}</Text>
<TouchableOpacity <TouchableOpacity
style={styles.contactIconButton} style={[styles.actionBtn, { backgroundColor: tint(0.08) }]}
onPress={() => handleEmailPress(item.email)} onPress={() => handleEmailPress(item.email)}
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}> hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}>
<Icon name="mail" size={20} color={colors.icon} /> <Icon name="send-outline" size={12} color={accent} />
</TouchableOpacity> </TouchableOpacity>
</View> </View>
) : null} )}
{/* Phone Row */} {item.phonenumber && (
{item.phonenumber ? (
<View style={styles.contactRow}> <View style={styles.contactRow}>
<Text style={styles.contactText} numberOfLines={1}> <View style={[styles.iconWrap, { backgroundColor: tint(0.08) }]}>
{item.phonenumber} <Icon name="call-outline" size={13} color={accent} />
</Text> </View>
<Text style={styles.contactText} numberOfLines={1}>{item.phonenumber}</Text>
<TouchableOpacity <TouchableOpacity
style={styles.contactIconButton} style={[styles.actionBtn, { backgroundColor: tint(0.08) }]}
onPress={() => handlePhonePress(item.phonenumber)} onPress={() => handlePhonePress(item.phonenumber)}
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}> hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}>
<Icon name="call" size={20} color={colors.icon} /> <Icon name="call-outline" size={12} color={accent} />
</TouchableOpacity> </TouchableOpacity>
</View> </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> </View>
</TouchableOpacity> </TouchableOpacity>
); );

View File

@ -9,18 +9,25 @@ export const getStyles = (colors: ThemeColors) =>
backgroundColor: colors.surface, backgroundColor: colors.surface,
borderWidth: 1, borderWidth: 1,
borderColor: colors.border, borderColor: colors.border,
borderRadius: 12, borderRadius: 14,
paddingHorizontal: 12, paddingHorizontal: 14,
height: 48, height: 48,
// Branded shadow — subtle indigo glow
shadowColor: '#5B4CF5',
shadowOffset: { width: 0, height: 3 },
shadowOpacity: 0.07,
shadowRadius: 8,
elevation: 2,
}, },
searchIcon: { searchIcon: {
marginRight: 8, marginRight: 9,
}, },
input: { input: {
flex: 1, flex: 1,
fontSize: 15, fontSize: 14,
color: colors.text, color: colors.text,
paddingVertical: 0, paddingVertical: 0,
letterSpacing: 0.1,
}, },
clearButton: { clearButton: {
marginLeft: 8, marginLeft: 8,

View File

@ -0,0 +1,2 @@
export * from './statCard';
export * from './statCard.props';

View File

@ -0,0 +1,7 @@
export interface StatCardProps {
label: string;
count: number;
color: string;
isSelected?: boolean;
onPress?: () => void;
}

View 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,
},
});

View 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>
);
};

View File

@ -1,11 +1,6 @@
import React, { useEffect } from 'react'; import React, { useEffect } from 'react';
import { import { View, Text, ScrollView, TouchableOpacity } from 'react-native';
View, import Icon from 'react-native-vector-icons/Ionicons';
Text,
ScrollView,
Linking,
Platform,
} from 'react-native';
import { RouteProp, useRoute, useNavigation } from '@react-navigation/native'; import { RouteProp, useRoute, useNavigation } from '@react-navigation/native';
import { NativeStackNavigationProp } from '@react-navigation/native-stack'; import { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { useTheme } from '@theme'; import { useTheme } from '@theme';
@ -14,14 +9,10 @@ import { getLeadDetails } from './thunk';
import { getStyles } from './leadDetails.styles'; import { getStyles } from './leadDetails.styles';
import { LeadsStackParamList } from '../../navigation/leadsStack'; import { LeadsStackParamList } from '../../navigation/leadsStack';
import { Loader, LeadDetailHeader, ActionCard } from '@components'; import { Loader, LeadDetailHeader, ActionCard } from '@components';
import { handleEmailPress, handlePhonePress } from '@utils'; import { handleEmailPress, handlePhonePress, handleWebsitePress, formatDate, route as routes } from '@utils';
import { route as routes } from '@utils';
type LeadDetailsRouteProp = RouteProp<LeadsStackParamList, 'leadDetails'>; type LeadDetailsRouteProp = RouteProp<LeadsStackParamList, 'leadDetails'>;
type LeadDetailsNavigationProp = NativeStackNavigationProp< type LeadDetailsNavigationProp = NativeStackNavigationProp<LeadsStackParamList, 'leadDetails'>;
LeadsStackParamList,
'leadDetails'
>;
export const LeadDetailsScreen = () => { export const LeadDetailsScreen = () => {
const route = useRoute<LeadDetailsRouteProp>(); const route = useRoute<LeadDetailsRouteProp>();
@ -30,35 +21,23 @@ export const LeadDetailsScreen = () => {
const { theme: colors } = useTheme(); const { theme: colors } = useTheme();
const styles = getStyles(colors); const styles = getStyles(colors);
// Get lead ID from route params
const leadId = route.params?.lead?.id; const leadId = route.params?.lead?.id;
const { item: lead, loading, error } = useAppSelector((state: RootState) => state.leadDetails);
// Get data from Redux store
const { item: lead, loading, error } = useAppSelector(
(state: RootState) => state.leadDetails,
);
const user_data = useAppSelector((state: RootState) => state.auth.user_data); const user_data = useAppSelector((state: RootState) => state.auth.user_data);
// Fetch lead details on mount
useEffect(() => { useEffect(() => {
if (leadId && user_data?.staffid) { if (leadId && user_data?.staffid) {
dispatch(getLeadDetails({ leadId, staffid: user_data.staffid })); dispatch(getLeadDetails({ leadId, staffid: user_data.staffid }));
} }
}, [dispatch, leadId, user_data]); }, [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) { if (error) {
return ( return (
<View style={styles.container}> <View style={styles.container}>
<View style={{ flex: 1, justifyContent: 'center', alignItems: 'center', padding: 32 }}> <View style={styles.errorContainer}>
<Text style={{ color: '#EF4444', fontSize: 14, textAlign: 'center' }}> <Text style={styles.errorText}>{error}</Text>
{error}
</Text>
</View> </View>
</View> </View>
); );
@ -67,50 +46,19 @@ export const LeadDetailsScreen = () => {
if (!lead) { if (!lead) {
return ( return (
<View style={styles.container}> <View style={styles.container}>
<View style={styles.errorContainer}>
<Text style={styles.infoValue}>No lead data available</Text> <Text style={styles.infoValue}>No lead data available</Text>
</View> </View>
</View>
); );
} }
// Handle status press (for future status change functionality) const addressString = [lead.address, lead.city, lead.state, lead.country]
const handleStatusPress = () => { .filter(Boolean)
// TODO: Show status list modal/picker .join(', ');
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');
};
return ( return (
<ScrollView style={styles.container} contentContainerStyle={styles.scrollContent}> <ScrollView style={styles.container} contentContainerStyle={styles.scrollContent}>
{/* Header with Avatar */}
<LeadDetailHeader <LeadDetailHeader
name={lead.name} name={lead.name}
company={lead.company} company={lead.company}
@ -118,35 +66,139 @@ export const LeadDetailsScreen = () => {
phonenumber={lead.phonenumber} phonenumber={lead.phonenumber}
statusName={lead.status_name || lead.status} statusName={lead.status_name || lead.status}
statusColor={lead.color} statusColor={lead.color}
onStatusPress={handleStatusPress}
onEmailPress={() => handleEmailPress(lead.email)} onEmailPress={() => handleEmailPress(lead.email)}
onPhonePress={() => handlePhonePress(lead.phonenumber)} 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}> <View style={styles.cardsContainer}>
<ActionCard <ActionCard
title="Proposals" title="Proposals"
icon="document-text-outline" icon="document-text-outline"
count={0} count={0}
onAddPress={handleAddProposal} onAddPress={() => navigation.navigate(routes.leadProposals, { leadId: lead.id })}
onViewAllPress={handleViewAllProposals} onViewAllPress={() => navigation.navigate(routes.leadProposals, { leadId: lead.id })}
/> />
<ActionCard <ActionCard
title="Tasks" title="Tasks"
icon="checkbox-outline" icon="checkbox-outline"
count={0} count={0}
onAddPress={handleAddTask} onAddPress={() => navigation.navigate(routes.leadTasks, { leadId: lead.id })}
onViewAllPress={handleViewAllTasks} onViewAllPress={() => navigation.navigate(routes.leadTasks, { leadId: lead.id })}
/> />
<ActionCard <ActionCard
title="Notes" title="Notes"
icon="create-outline" icon="create-outline"
count={0} count={0}
onAddPress={handleAddNote} onAddPress={() => navigation.navigate(routes.leadNotes, { leadId: lead.id })}
onViewAllPress={handleViewAllNotes} onViewAllPress={() => navigation.navigate(routes.leadNotes, { leadId: lead.id })}
/> />
</View> </View>
</ScrollView> </ScrollView>

View File

@ -9,114 +9,102 @@ export const getStyles = (colors: ThemeColors) =>
}, },
scrollContent: { scrollContent: {
padding: 16, padding: 16,
}, paddingBottom: 32,
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,
}, },
sectionTitle: { sectionTitle: {
fontSize: 16, fontSize: 15,
fontWeight: '700', fontWeight: '700',
color: colors.text, color: colors.text,
marginBottom: 12, 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: { infoRow: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
marginBottom: 12, paddingVertical: 9,
gap: 12,
}, },
infoIcon: { infoIconWrap: {
width: 40, width: 32,
height: 40, height: 32,
borderRadius: 20, borderRadius: 10,
backgroundColor: colors.surface, backgroundColor: colors.surface,
justifyContent: 'center', justifyContent: 'center',
alignItems: 'center', alignItems: 'center',
marginRight: 12, borderWidth: 1,
borderColor: colors.border,
}, },
infoContent: { infoMeta: {
flex: 1, flex: 1,
}, },
infoLabel: { infoLabel: {
fontSize: 12, fontSize: 11,
color: colors.textMuted, color: colors.textMuted,
marginBottom: 2, marginBottom: 2,
}, },
infoValue: { infoValue: {
fontSize: 14, fontSize: 13,
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,
fontWeight: '600', fontWeight: '600',
color: '#FFFFFF', color: colors.text,
marginLeft: 8,
}, },
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, fontSize: 14,
color: colors.textSecondary, textAlign: 'center',
lineHeight: 20,
}, },
}); });

View File

@ -3,7 +3,9 @@ import {
Text, Text,
View, View,
FlatList, FlatList,
ScrollView,
TouchableOpacity, TouchableOpacity,
ActivityIndicator,
} from 'react-native'; } from 'react-native';
import { useNavigation } from '@react-navigation/native'; import { useNavigation } from '@react-navigation/native';
import { NativeStackNavigationProp } from '@react-navigation/native-stack'; 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 { getStyles } from './leads.styles';
import { useTheme } from '@theme'; import { useTheme } from '@theme';
import { useAppDispatch, useAppSelector, RootState } from '@store'; import { useAppDispatch, useAppSelector, RootState } from '@store';
import { getLeads } from './thunk'; import { getLeads, getLeadCountList } from './thunk';
import { LeadItem } from '@interfaces'; import { LeadItem } from '@interfaces';
import { LeadItemCard, SearchInput, Loader } from '@components'; import { LeadItemCard, SearchInput, Loader, StatCard } from '@components';
import { LeadsStackParamList } from '../../navigation/leadsStack'; import { LeadsStackParamList } from '../../navigation/leadsStack';
import { route } from '@utils'; import { route } from '@utils';
@ -28,8 +30,9 @@ export const LeadsScreen = () => {
const { theme: colors } = useTheme(); const { theme: colors } = useTheme();
const styles = getStyles(colors); const styles = getStyles(colors);
const [searchTerm, setSearchTerm] = useState(''); 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, (state: RootState) => state.leads,
); );
const user_data = useAppSelector((state: RootState) => state.auth.user_data); const user_data = useAppSelector((state: RootState) => state.auth.user_data);
@ -37,20 +40,30 @@ export const LeadsScreen = () => {
useEffect(() => { useEffect(() => {
if (user_data?.staffid) { if (user_data?.staffid) {
dispatch(getLeads({ leadId: null, staffid: user_data.staffid })); dispatch(getLeads({ leadId: null, staffid: user_data.staffid }));
dispatch(getLeadCountList(user_data.staffid));
} }
}, [dispatch, user_data]); }, [dispatch, user_data]);
const filteredLeads = searchTerm const filteredLeads = items.filter(lead => {
? items.filter( const matchesSearch = searchTerm
lead => ? lead.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
lead.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
lead.company.toLowerCase().includes(searchTerm.toLowerCase()) || lead.company.toLowerCase().includes(searchTerm.toLowerCase()) ||
lead.email.toLowerCase().includes(searchTerm.toLowerCase()), lead.email.toLowerCase().includes(searchTerm.toLowerCase())
) : true;
: items;
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) => { const handleLeadPress = (lead: LeadItem) => {
// Navigate to lead details screen
navigation.navigate(route.leadDetails, { lead }); navigation.navigate(route.leadDetails, { lead });
}; };
@ -60,6 +73,7 @@ export const LeadsScreen = () => {
return ( return (
<View style={styles.container}> <View style={styles.container}>
{/* Search + Filter Header (Fixed at top) */}
<View style={styles.header}> <View style={styles.header}>
<SearchInput <SearchInput
value={searchTerm} value={searchTerm}
@ -72,6 +86,32 @@ export const LeadsScreen = () => {
</TouchableOpacity> </TouchableOpacity>
</View> </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 ? ( {loading ? (
<Loader message="Loading leads..." /> <Loader message="Loading leads..." />
) : error ? ( ) : error ? (
@ -82,11 +122,20 @@ export const LeadsScreen = () => {
<FlatList <FlatList
data={filteredLeads} data={filteredLeads}
keyExtractor={item => item.id} keyExtractor={item => item.id}
style={{ flex: 1 }}
contentContainerStyle={styles.listContent} contentContainerStyle={styles.listContent}
renderItem={renderLead} renderItem={renderLead}
ListEmptyComponent={() => ( ListEmptyComponent={() => (
<View style={styles.emptyContainer}> <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> </View>
)} )}
/> />

View File

@ -7,9 +7,22 @@ export const getStyles = (colors: ThemeColors) =>
flex: 1, flex: 1,
backgroundColor: colors.background, backgroundColor: colors.background,
}, },
countsLoading: {
height: 82,
justifyContent: 'center',
alignItems: 'center',
},
countsScroll: {
paddingHorizontal: 16,
paddingTop: 4,
paddingBottom: 10,
gap: 10,
},
header: { header: {
flexDirection: 'row', flexDirection: 'row',
padding: 16, paddingHorizontal: 16,
paddingTop: 12,
paddingBottom: 10,
alignItems: 'center', alignItems: 'center',
gap: 10, gap: 10,
}, },
@ -22,13 +35,20 @@ export const getStyles = (colors: ThemeColors) =>
backgroundColor: colors.surface, backgroundColor: colors.surface,
borderWidth: 1, borderWidth: 1,
borderColor: colors.border, borderColor: colors.border,
borderRadius: 12, borderRadius: 14,
justifyContent: 'center', justifyContent: 'center',
alignItems: 'center', alignItems: 'center',
// Branded indigo shadow
shadowColor: '#5B4CF5',
shadowOffset: { width: 0, height: 3 },
shadowOpacity: 0.12,
shadowRadius: 6,
elevation: 3,
}, },
listContent: { listContent: {
padding: 16, paddingHorizontal: 16,
paddingTop: 0, paddingTop: 6,
paddingBottom: 40,
}, },
errorContainer: { errorContainer: {
flex: 1, flex: 1,
@ -42,14 +62,40 @@ export const getStyles = (colors: ThemeColors) =>
textAlign: 'center', textAlign: 'center',
}, },
emptyContainer: { emptyContainer: {
flex: 1, alignItems: 'center',
paddingTop: 80,
padding: 32,
gap: 10,
},
emptyIconWrap: {
width: 76,
height: 76,
borderRadius: 38,
backgroundColor: colors.surface,
justifyContent: 'center', justifyContent: 'center',
alignItems: '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: { emptyText: {
color: colors.textSecondary, color: colors.textSecondary,
fontSize: 14, fontSize: 13,
textAlign: 'center', textAlign: 'center',
lineHeight: 20,
maxWidth: 280,
}, },
}); });

View File

@ -1,17 +1,23 @@
import { createReducer } from '@reduxjs/toolkit'; import { createReducer } from '@reduxjs/toolkit';
import { getLeads } from './thunk'; import { getLeads, getLeadCountList } from './thunk';
import { LeadItem } from '@interfaces'; import { LeadItem, LeadCountItem } from '@interfaces';
export interface LeadsState { export interface LeadsState {
items: LeadItem[]; items: LeadItem[];
loading: boolean; loading: boolean;
error: string | null; error: string | null;
counts: LeadCountItem[];
countsLoading: boolean;
countsError: string | null;
} }
const initialState: LeadsState = { const initialState: LeadsState = {
items: [], items: [],
loading: false, loading: false,
error: null, error: null,
counts: [],
countsLoading: false,
countsError: null,
}; };
export const reducers = createReducer(initialState, builder => { export const reducers = createReducer(initialState, builder => {
@ -31,6 +37,22 @@ export const reducers = createReducer(initialState, builder => {
(action.payload as string) ?? (action.payload as string) ??
action.error.message ?? action.error.message ??
'Failed to load leads'; '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';
}); });
}); });

View File

@ -1,6 +1,6 @@
import { createAsyncThunk } from '@reduxjs/toolkit'; import { createAsyncThunk } from '@reduxjs/toolkit';
import { getLeadsApi } from '@api'; import { getLeadsApi, getLeadCountListApi } from '@api';
import { LeadItem } from '@interfaces'; import { LeadItem, LeadCountItem } from '@interfaces';
export const getLeads = createAsyncThunk< export const getLeads = createAsyncThunk<
LeadItem[], LeadItem[],
@ -12,3 +12,14 @@ export const getLeads = createAsyncThunk<
return rejectWithValue(error.message || 'Failed to load leads'); 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');
}
});

View File

@ -2,29 +2,37 @@ import React, { useState, useEffect } from 'react';
import { import {
Text, Text,
View, View,
TextInput,
TouchableOpacity, TouchableOpacity,
KeyboardAvoidingView, KeyboardAvoidingView,
Platform, Platform,
ScrollView, ScrollView,
ImageBackground,
ActivityIndicator,
StatusBar,
} from 'react-native'; } from 'react-native';
import { useNavigation } from '@react-navigation/native'; import { useNavigation } from '@react-navigation/native';
import Icon from 'react-native-vector-icons/Ionicons'; import Icon from 'react-native-vector-icons/Ionicons';
import { getStyles } from './login.styles'; import { getStyles } from './login.styles';
import { useTheme } from '../../theme'; import { useTheme } from '@theme';
import { useAppDispatch, useAppSelector } from '../../store/store'; import { useAppDispatch, useAppSelector, RootState, login } from '@store';
import { login } from '../../store/commonReducers/auth/thunk';
import { LoginRequest } from '@interfaces'; 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 = () => { export const LoginScreen = () => {
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const navigation = useNavigation<any>(); const navigation = useNavigation<any>();
const { theme: colors } = useTheme(); const { theme: colors, isDark } = useTheme();
const styles = getStyles(colors); const styles = getStyles(colors, isDark);
const [tenancy, setTenancy] = useState(''); const [tenancy, setTenancy] = useState('');
const [email, setEmail] = useState(''); const [email, setEmail] = useState('');
const [password, setPassword] = useState(''); const [password, setPassword] = useState('');
const [showPassword, setShowPassword] = useState(false);
const [focusedField, setFocusedField] = useState<string | null>(null);
const [localError, setLocalError] = useState(''); const [localError, setLocalError] = useState('');
const { loginLoading, loginError, loginSuccess, token, user_data } = const { loginLoading, loginError, loginSuccess, token, user_data } =
@ -40,19 +48,68 @@ export const LoginScreen = () => {
setLocalError('Email address is required'); setLocalError('Email address is required');
return; return;
} }
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(email.trim())) {
setLocalError('Please enter a valid email address');
return;
}
if (!password.trim()) { if (!password.trim()) {
setLocalError('Password is required'); setLocalError('Password is required');
return; return;
} }
const deviceToken = await NotificationService.getFCMToken();
console.log('deviceToken-', deviceToken)
const payload: LoginRequest = { const payload: LoginRequest = {
email, email,
password, 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(() => { useEffect(() => {
if (loginSuccess && token && user_data) { if (loginSuccess && token && user_data) {
@ -64,117 +121,164 @@ export const LoginScreen = () => {
}, [loginSuccess, token, user_data, navigation]); }, [loginSuccess, token, user_data, navigation]);
return ( return (
<ImageBackground
source={backGroundImage}
style={styles.backgroundImage}
resizeMode="cover"
>
<StatusBar
barStyle={isDark ? 'light-content' : 'dark-content'}
backgroundColor="transparent"
translucent
/>
<View style={styles.overlay}>
<KeyboardAvoidingView <KeyboardAvoidingView
behavior={Platform.OS === 'ios' ? 'padding' : 'height'} behavior={Platform.OS === 'ios' ? 'padding' : undefined}
style={styles.container} style={styles.container}
> >
<ScrollView <ScrollView
contentContainerStyle={styles.scrollContainer} contentContainerStyle={styles.scrollContainer}
keyboardShouldPersistTaps="handled" keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false}
bounces={false}
> >
<View style={styles.headerContainer}> {/* Top Area Spacer displaying background illustration */}
<View style={styles.logoCircle}> <View style={styles.topSpacer} />
<Icon name="cube" size={40} color={colors.icon} />
</View> {/* Bottom Sheet Form Card */}
<Text style={styles.title}>Convex CRM</Text> <View style={styles.bottomSheetCard}>
<Text style={styles.subtitle}> <View style={styles.sheetHandle} />
Enter details to access your workspace
<View style={styles.cardHeader}>
<Text style={styles.welcomeTitle}>Sign In</Text>
<Text style={styles.welcomeSubtitle}>
Enter your credentials to continue
</Text> </Text>
</View> </View>
<View style={styles.formCard}>
{localError || loginError ? ( {localError || loginError ? (
<View style={styles.errorBanner}> <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> <Text style={styles.errorText}>{localError || loginError}</Text>
</View> </View>
) : null} ) : null}
{/* Tenancy Field */} {/* Tenancy Field */}
<View style={styles.inputContainer}> <FormInput
<Text style={styles.label}>Tenancy Name</Text> label="Tenancy Name"
<View style={styles.inputWrapper}> 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 <Icon
name="business-outline" name="business-outline"
size={20} size={20}
color={colors.textSecondary} color={
style={styles.inputIcon} 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 */} {/* Email Field */}
<View style={styles.inputContainer}> <FormInput
<Text style={styles.label}>Email Address</Text> label="Email Address"
<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}
value={email} value={email}
onChangeText={setEmail} onChangeText={setEmail}
placeholder="name@company.com"
keyboardType="email-address" keyboardType="email-address"
autoCapitalize="none" autoCapitalize="none"
autoCorrect={false} 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 */} {/* Password Field */}
<View style={styles.inputContainer}> <FormInput
<Text style={styles.label}>Password</Text> label="Password"
<View style={styles.inputWrapper}> 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 <Icon
name="lock-closed-outline" name="lock-closed-outline"
size={20} size={20}
color={colors.textSecondary} color={
style={styles.inputIcon} focusedField === 'password'
? colors.icon
: colors.textSecondary
}
/> />
<TextInput }
style={styles.input} rightIcon={
placeholder="••••••••" <TouchableOpacity
placeholderTextColor={colors.textMuted} onPress={() => setShowPassword(!showPassword)}
value={password} style={styles.eyeIconButton}
onChangeText={setPassword} activeOpacity={0.7}
secureTextEntry >
autoCapitalize="none" <Icon
autoCorrect={false} name={showPassword ? 'eye-off-outline' : 'eye-outline'}
size={20}
color={colors.textMuted}
/>
</TouchableOpacity>
}
/> />
</View>
</View>
{/* Sign In Button */} {/* Sign In Button */}
<TouchableOpacity <TouchableOpacity
style={styles.button} style={[styles.button, loginLoading && styles.buttonDisabled]}
activeOpacity={0.8} activeOpacity={0.85}
onPress={handleLogin} onPress={handleLogin}
disabled={loginLoading} disabled={loginLoading}
> >
<Text style={styles.buttonText}> {loginLoading ? (
{loginLoading ? 'Signing In...' : 'Sign In'} <ActivityIndicator size="small" color="#FFFFFF" />
</Text> ) : (
<Text style={styles.buttonText}>Sign In</Text>
)}
</TouchableOpacity> </TouchableOpacity>
</View> </View>
<View style={styles.footer}>
<Text style={styles.footerText}>Secure SSL encrypted workspace</Text>
</View>
</ScrollView> </ScrollView>
</KeyboardAvoidingView> </KeyboardAvoidingView>
</View>
</ImageBackground>
); );
}; };

View File

@ -1,128 +1,129 @@
import { StyleSheet } from 'react-native'; import { StyleSheet, Platform } from 'react-native';
import { ThemeColors } from '../../theme'; import { ThemeColors } from '../../theme';
export const getStyles = (colors: ThemeColors) => export const getStyles = (colors: ThemeColors, isDark: boolean) =>
StyleSheet.create({ StyleSheet.create({
container: { container: {
flex: 1, 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: { scrollContainer: {
flexGrow: 1, flexGrow: 1,
justifyContent: 'center', justifyContent: 'space-between',
padding: 24,
}, },
headerContainer: { topSpacer: {
alignItems: 'center', flex: 1,
marginBottom: 32, minHeight: 120,
}, },
logoCircle: { bottomSheetCard: {
width: 80, backgroundColor: isDark
height: 80, ? 'rgba(26, 29, 58, 0.96)'
borderRadius: 40, : 'rgba(255, 255, 255, 0.98)',
backgroundColor: colors.border, borderTopLeftRadius: 32,
justifyContent: 'center', borderTopRightRadius: 32,
alignItems: 'center', 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, marginBottom: 16,
shadowColor: colors.icon,
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.3,
shadowRadius: 10,
elevation: 8,
}, },
title: { cardHeader: {
fontSize: 28, marginBottom: 18,
},
welcomeTitle: {
fontSize: 22,
fontWeight: '800', fontWeight: '800',
color: colors.text, color: colors.text,
letterSpacing: 0.5, letterSpacing: 0.2,
}, },
subtitle: { welcomeSubtitle: {
fontSize: 14, fontSize: 13,
color: colors.textSecondary, color: colors.textSecondary,
marginTop: 8, marginTop: 4,
textAlign: 'center', fontWeight: '400',
},
formCard: {
backgroundColor: colors.card,
borderRadius: 16,
padding: 24,
shadowColor: '#000',
shadowOffset: { width: 0, height: 10 },
shadowOpacity: 0.25,
shadowRadius: 15,
elevation: 10,
}, },
errorBanner: { errorBanner: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
backgroundColor: '#451A1A', backgroundColor: isDark ? 'rgba(239, 68, 68, 0.15)' : '#FEF2F2',
borderRadius: 8, borderRadius: 12,
padding: 12, padding: 12,
marginBottom: 16, marginBottom: 16,
borderWidth: 1, borderWidth: 1,
borderColor: '#7F1D1D', borderColor: isDark ? 'rgba(239, 68, 68, 0.3)' : '#FCA5A5',
}, },
errorText: { errorText: {
color: '#FCA5A5', color: '#EF4444',
fontSize: 13, fontSize: 13,
marginLeft: 8, fontWeight: '500',
marginLeft: 10,
flex: 1, flex: 1,
}, },
inputContainer: { fieldContainer: {
marginBottom: 20, marginBottom: 14,
},
label: {
fontSize: 13,
fontWeight: '600',
color: colors.textSecondary,
marginBottom: 8,
textTransform: 'uppercase',
letterSpacing: 0.5,
}, },
inputWrapper: { inputWrapper: {
flexDirection: 'row', backgroundColor: isDark ? 'rgba(15, 16, 34, 0.6)' : '#F8FAFC',
alignItems: 'center', borderRadius: 14,
backgroundColor: colors.background, borderWidth: 1.5,
borderRadius: 10,
borderWidth: 1,
borderBottomColor: colors.border,
borderColor: colors.border, borderColor: colors.border,
paddingHorizontal: 12, height: 50,
}, },
inputIcon: { inputWrapperFocused: {
marginRight: 10, borderColor: colors.icon,
backgroundColor: isDark ? 'rgba(15, 16, 34, 0.85)' : '#FFFFFF',
}, },
input: { eyeIconButton: {
flex: 1, padding: 6,
height: 48,
color: colors.text,
fontSize: 15,
}, },
button: { button: {
backgroundColor: colors.icon, backgroundColor: colors.icon,
borderRadius: 10, borderRadius: 14,
height: 50, height: 52,
justifyContent: 'center', justifyContent: 'center',
alignItems: 'center', alignItems: 'center',
marginTop: 8, marginTop: 10,
shadowColor: colors.icon, shadowColor: colors.icon,
shadowOffset: { width: 0, height: 4 }, shadowOffset: { width: 0, height: 6 },
shadowOpacity: 0.4, shadowOpacity: 0.35,
shadowRadius: 6, shadowRadius: 12,
elevation: 5, elevation: 6,
},
buttonDisabled: {
opacity: 0.7,
}, },
buttonText: { buttonText: {
color: '#FFFFFF', color: '#FFFFFF',
fontSize: 16, fontSize: 16,
fontWeight: '700', fontWeight: '700',
letterSpacing: 0.5, letterSpacing: 0.4,
},
footer: {
alignItems: 'center',
marginTop: 32,
},
footerText: {
fontSize: 12,
color: colors.textMuted,
}, },
}); });

View File

@ -1,20 +1,21 @@
import React from 'react'; import React from 'react';
import { Text, View, TouchableOpacity, ScrollView, Switch } from 'react-native'; 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 { useNavigation } from '@react-navigation/native';
import Icon from 'react-native-vector-icons/Ionicons'; import Icon from 'react-native-vector-icons/Ionicons';
import { getStyles } from './profile.styles'; import { getStyles } from './profile.styles';
import { useTheme } from '../../theme'; import { useTheme } from '../../theme';
import { useAppDispatch, logout } from '@store';
export const ProfileScreen = () => { export const ProfileScreen = () => {
const navigation = useNavigation<any>(); const navigation = useNavigation<any>();
const { theme: colors, isDark, toggleTheme } = useTheme(); const { theme: colors, isDark, toggleTheme } = useTheme();
const styles = getStyles(colors); const styles = getStyles(colors);
const dispatch = useAppDispatch();
const handleLogout = async () => { const handleLogout = async () => {
try { try {
await AsyncStorage.removeItem('userToken'); dispatch(logout());
await AsyncStorage.removeItem('tenancyName');
} catch (e) { } catch (e) {
console.error(e); console.error(e);
} }

View File

@ -2,3 +2,4 @@ export * from './drawerItem';
export * from './auth'; export * from './auth';
export * from './leads'; export * from './leads';
export * from './leadDetails'; export * from './leadDetails';
export * from './list';

View File

@ -46,3 +46,12 @@ export interface LeadItem {
status_name: string; status_name: string;
source_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
View 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;
}

View File

@ -1,13 +1,14 @@
import React from 'react'; import React from 'react';
import { Text, View, TouchableOpacity, ScrollView } from 'react-native'; import { Text, View, TouchableOpacity, ScrollView } from 'react-native';
import { DrawerContentComponentProps } from '@react-navigation/drawer'; import { DrawerContentComponentProps } from '@react-navigation/drawer';
import AsyncStorage from '@react-native-async-storage/async-storage';
import Icon from 'react-native-vector-icons/Ionicons'; import Icon from 'react-native-vector-icons/Ionicons';
import { menuItems } from '@mock-data'; import { menuItems } from '@mock-data';
import { useTheme } from '@theme'; import { useTheme } from '@theme';
import { DrawerItemProps } from '@interfaces'; import { DrawerItemProps } from '@interfaces';
import { getStyles } from './customDrawerContent.style'; import { getStyles } from './customDrawerContent.style';
import { useAppDispatch, logout } from '@store';
const DrawerItem = ({ label, iconName, focused, onPress }: DrawerItemProps) => { const DrawerItem = ({ label, iconName, focused, onPress }: DrawerItemProps) => {
const { theme: colors } = useTheme(); const { theme: colors } = useTheme();
const styles = getStyles(colors); const styles = getStyles(colors);
@ -35,11 +36,11 @@ export const CustomDrawerContent = (props: DrawerContentComponentProps) => {
const { state, navigation } = props; const { state, navigation } = props;
const { theme: colors } = useTheme(); const { theme: colors } = useTheme();
const styles = getStyles(colors); const styles = getStyles(colors);
const dispatch = useAppDispatch();
const handleLogout = async () => { const handleLogout = async () => {
try { try {
await AsyncStorage.removeItem('userToken'); dispatch(logout());
await AsyncStorage.removeItem('tenancyName');
} catch (e) { } catch (e) {
console.error(e); console.error(e);
} }

View File

@ -29,6 +29,7 @@ const Drawer = createDrawerNavigator<DrawerStackParamList>();
export const DrawerStack = () => { export const DrawerStack = () => {
const { theme: colors } = useTheme(); const { theme: colors } = useTheme();
return ( return (
<Drawer.Navigator <Drawer.Navigator
drawerContent={CustomDrawerContent} drawerContent={CustomDrawerContent}
@ -36,19 +37,17 @@ export const DrawerStack = () => {
swipeEnabled: false, swipeEnabled: false,
headerStyle: { headerStyle: {
backgroundColor: colors.header, backgroundColor: colors.header,
elevation: 0,
shadowOpacity: 0,
}, },
headerTitleStyle: { headerTitleStyle: {
fontSize: 16, fontSize: 17,
fontWeight: '700', fontWeight: '700',
color: colors.text, color: colors.text,
letterSpacing: 0.2,
}, },
headerTitleAlign: 'center', headerTitleAlign: 'center',
headerTintColor: colors.text, headerTintColor: colors.text,
}} headerShadowVisible: false,
> }}>
{/* Tab Navigator has its own headers per tab, so hide Drawer header for home */}
<Drawer.Screen <Drawer.Screen
name="home" name="home"
component={TabStack} component={TabStack}

View File

@ -27,17 +27,18 @@ export const LeadsStack = () => {
backgroundColor: colors.header, backgroundColor: colors.header,
}, },
headerTitleStyle: { headerTitleStyle: {
fontSize: 16, fontSize: 17,
fontWeight: '700', fontWeight: '700',
color: colors.text, color: colors.text,
}, },
headerTitleAlign: 'center', headerTitleAlign: 'center',
headerTintColor: colors.text, headerTintColor: colors.text,
headerShadowVisible: false,
}}> }}>
<Stack.Screen <Stack.Screen
name={route.leads} name={route.leads}
component={LeadsScreen} component={LeadsScreen}
options={{ headerTitle: 'Leads' }} options={{ headerTitle: 'My Leads' }}
/> />
<Stack.Screen <Stack.Screen
name={route.leadDetails} name={route.leadDetails}

View File

@ -15,7 +15,6 @@ const RootStack = createNativeStackNavigator<RootStackParamList>();
export const RootNavigator = () => { export const RootNavigator = () => {
const { token } = useAppSelector((state: RootState) => state.auth); const { token } = useAppSelector((state: RootState) => state.auth);
console.log('RootNavigator token:', token); // Debugging line to check the token value
const initialRouteName = token ? 'DrawerStack' : 'AuthStack'; const initialRouteName = token ? 'DrawerStack' : 'AuthStack';
return ( return (

View File

@ -1,4 +1,4 @@
import { StyleSheet } from 'react-native'; import { StyleSheet, Platform } from 'react-native';
import { ThemeColors } from '@theme'; import { ThemeColors } from '@theme';
export const getStyles = (colors: ThemeColors) => export const getStyles = (colors: ThemeColors) =>
@ -7,30 +7,45 @@ export const getStyles = (colors: ThemeColors) =>
backgroundColor: colors.tabBar, backgroundColor: colors.tabBar,
borderTopWidth: 1, borderTopWidth: 1,
borderTopColor: colors.border, borderTopColor: colors.border,
height: 60, height: Platform.OS === 'ios' ? 84 : 64,
paddingBottom: 8, paddingBottom: Platform.OS === 'ios' ? 24 : 10,
paddingTop: 8, // paddingTop: 8,
shadowColor: '#5B4CF5',
shadowOffset: { width: 0, height: -4 },
shadowOpacity: 0.06,
shadowRadius: 12,
elevation: 8,
}, },
tabBarLabel: { tabBarLabel: {
fontSize: 11, fontSize: 12,
fontWeight: '600', fontWeight: '600',
letterSpacing: 0.2,
}, },
header: { header: {
backgroundColor: colors.header, backgroundColor: colors.header,
borderBottomWidth: 1, borderBottomWidth: 1,
borderBottomColor: colors.border, borderBottomColor: colors.border,
elevation: 0, shadowColor: '#5B4CF5',
shadowOpacity: 0, shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.04,
shadowRadius: 6,
elevation: 2,
}, },
headerTitle: { headerTitle: {
fontSize: 17, fontSize: 17,
fontWeight: '800', fontWeight: '700',
color: colors.text, color: colors.text,
letterSpacing: 0.2,
}, },
drawerButton: { drawerButton: {
paddingHorizontal: 16, width: 38,
height: '100%', height: 38,
borderRadius: 12,
justifyContent: 'center', justifyContent: 'center',
alignItems: 'center', alignItems: 'center',
marginLeft: 14,
backgroundColor: colors.surface,
borderWidth: 1,
borderColor: colors.border,
}, },
}); });

View File

@ -37,11 +37,11 @@ export const TabStack = () => {
} else if (tabRoute.name === route.addLead) { } else if (tabRoute.name === route.addLead) {
iconName = focused ? 'add-circle' : 'add-circle-outline'; iconName = focused ? 'add-circle' : 'add-circle-outline';
} else if (tabRoute.name === route.customers) { } else if (tabRoute.name === route.customers) {
iconName = focused ? 'business' : 'business-outline'; iconName = focused ? 'people' : 'people-outline';
} else if (tabRoute.name === route.profile) { } else if (tabRoute.name === route.profile) {
iconName = focused ? 'person' : 'person-outline'; 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, tabBarActiveTintColor: colors.icon,
tabBarInactiveTintColor: colors.textMuted, tabBarInactiveTintColor: colors.textMuted,
@ -50,28 +50,26 @@ export const TabStack = () => {
headerStyle: styles.header, headerStyle: styles.header,
headerTitleStyle: styles.headerTitle, headerTitleStyle: styles.headerTitle,
headerTitleAlign: 'center', headerTitleAlign: 'center',
})} })}>
>
<Tab.Screen <Tab.Screen
name={route.dashboard} name={route.dashboard}
component={DashboardScreen} component={DashboardScreen}
options={({ navigation }) => ({ options={({ navigation }) => ({
tabBarLabel: 'Dashboard',
headerTitle: 'Convex CRM', headerTitle: 'Convex CRM',
headerLeft: () => ( headerLeft: () => (
<TouchableOpacity <TouchableOpacity
style={styles.drawerButton} style={styles.drawerButton}
activeOpacity={0.7} activeOpacity={0.7}
onPress={() => { onPress={() => {
const parentNav = const parentNav = navigation.getParent<DrawerNavigationProp<any>>();
navigation.getParent<DrawerNavigationProp<any>>();
if (parentNav) { if (parentNav) {
parentNav.openDrawer(); parentNav.openDrawer();
} else { } else {
(navigation as any).openDrawer?.(); (navigation as any).openDrawer?.();
} }
}} }}>
> <Icon name="menu-outline" size={20} color={colors.text} />
<Icon name="menu-outline" size={26} color={colors.text} />
</TouchableOpacity> </TouchableOpacity>
), ),
})} })}
@ -79,11 +77,35 @@ export const TabStack = () => {
<Tab.Screen <Tab.Screen
name={route.leads} name={route.leads}
component={LeadsStack} 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> </Tab.Navigator>
); );
}; };

1
app/services/index.ts Normal file
View File

@ -0,0 +1 @@
export * from './notifications';

View 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,
};

View File

@ -24,7 +24,7 @@ const initialState: LoginState = {
loginMessage: null, loginMessage: null,
}; };
export const reducers = createReducer(initialState, builder => { export const authReducer = createReducer(initialState, builder => {
builder builder
.addCase(login.pending, acc => { .addCase(login.pending, acc => {
acc.loginLoading = true; acc.loginLoading = true;
@ -49,4 +49,4 @@ export const reducers = createReducer(initialState, builder => {
}); });
}); });
export default reducers; export default authReducer;

View File

@ -1,4 +1,4 @@
import { createAsyncThunk } from '@reduxjs/toolkit'; import { createAction, createAsyncThunk } from '@reduxjs/toolkit';
import { LoginRequest, LoginResponse } from '@interfaces'; import { LoginRequest, LoginResponse } from '@interfaces';
import { loginApi } from '@api'; import { loginApi } from '@api';
@ -12,3 +12,5 @@ export const login = createAsyncThunk<LoginResponse, LoginRequest>(
} }
}, },
); );
export const logout = createAction('USER_LOGOUT');

View File

@ -0,0 +1,2 @@
export * from './thunk';
export * from './reducers';

View 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;

View 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');
}
},
);

View File

@ -1 +1,4 @@
export * from './auth'; export * from './auth';
export * from './statuslist';
export * from './sourcelist';
export * from './countrylist';

View File

@ -0,0 +1,2 @@
export * from './thunk';
export * from './reducers';

View 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;

View 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');
}
},
);

View File

@ -0,0 +1,2 @@
export * from './thunk';
export * from './reducers';

View 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;

View 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');
}
},
);

View File

@ -1,13 +1,26 @@
import { combineReducers } from '@reduxjs/toolkit'; import { combineReducers } from '@reduxjs/toolkit';
import authReducer from './commonReducers/auth/reducers'; 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 leadsReducer from '../features/leads/reducers';
import leadDetailsReducer from '../features/leadDetails/reducers'; import leadDetailsReducer from '../features/leadDetails/reducers';
const rootReducer = combineReducers({ const appReducer = combineReducers({
auth: authReducer, auth: authReducer,
statusList: statusListReducer,
sourceList: sourceListReducer,
countryList: countryListReducer,
leads: leadsReducer, leads: leadsReducer,
leadDetails: leadDetailsReducer, 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; export default rootReducer;

View File

@ -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 = { export const colors = {
// Brand // ── Brand tokens ────────────────────────────────────────────────────────────
primary: '#1565C0', // deep blue (dark bg accent / light primary) primary: '#5B4CF5', // electric indigo — primary actions
primaryLight: '#1E90FF', // bright sky blue primaryLight: '#7C6FF7', // lighter indigo for hover / tint
accent: '#4FC3F7', // cyan highlight accent: '#8B5CF6', // violet — gradient end, highlights
accentAlt: '#06B6D4', // cyan — contrast accent (badges, chips)
// Light theme // ── Light theme ─────────────────────────────────────────────────────────────
light: { light: {
background: '#F0F6FF', background: '#F4F6FB', // soft cool white-grey — avoids pure white glare
surface: '#FFFFFF', surface: '#FFFFFF', // pure white surfaces (search, buttons)
border: '#BBDEFB', card: '#FFFFFF', // card bg — clean white with shadow
text: '#0A1A6B', border: '#E4E8F5', // subtle lavender-grey border
textSecondary: '#1E3A8A', text: '#1A1D3A', // near-black with slight blue tint — excellent contrast
textMuted: '#5C7BA8', textSecondary: '#4B5280', // medium slate — readable secondary text
icon: '#1565C0', textMuted: '#9399BB', // muted lavender — placeholders, dates
icon: '#5B4CF5', // brand indigo icons
tabBar: '#FFFFFF', tabBar: '#FFFFFF',
header: '#FFFFFF', header: '#FFFFFF',
drawerBg: '#FFFFFF', drawerBg: '#FFFFFF',
drawerHeader: '#0A237A', drawerHeader: '#1A1D3A',
card: '#FFFFFF',
}, },
// Dark theme // ── Dark theme ──────────────────────────────────────────────────────────────
dark: { dark: {
background: '#07112A', background: '#0F1022', // very deep blue-black — premium feel
surface: '#0D1F4A', surface: '#1A1D3A', // dark navy-indigo — elevated surfaces
border: '#1A3575', card: '#1E2140', // slightly lighter than surface for cards
text: '#E3F0FF', border: '#2D3260', // muted indigo border — visible but subtle
textSecondary: '#A8C8F5', text: '#EEF0FF', // near-white with slight violet tint
textMuted: '#5C7BA8', textSecondary: '#A5AACF', // soft lavender — readable secondary
icon: '#4FC3F7', textMuted: '#5C6290', // deep muted indigo — placeholders, dates
tabBar: '#0D1F4A', icon: '#7C6FF7', // light indigo icons
header: '#0D1F4A', tabBar: '#1A1D3A',
drawerBg: '#0A1A6B', header: '#1A1D3A',
drawerHeader: '#07112A', drawerBg: '#141630',
card: '#0D1F4A', drawerHeader: '#0F1022',
}, },
} as const; } as const;

View File

@ -1,2 +1,3 @@
export const backGroundImage = require('./background.png') export const backGroundImage = require('./background.png')
export const logo = require('./logo.png')

BIN
app/utils/assets/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

View File

@ -51,3 +51,19 @@ export const getInitials = (name?: string | null): string => {
return name.substring(0, 2).toUpperCase(); 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

View File

@ -0,0 +1,7 @@
{
"background": "#ffffff",
"logo": {
"width": 100,
"height": 100
}
}

BIN
assets/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

View File

@ -1,9 +1,12 @@
import { AppRegistry } from 'react-native'; import { AppRegistry } from 'react-native';
import App from './app/App'; import App from './app/App';
import { name as appName } from './app.json'; import { name as appName } from './app.json';
import { NotificationService } from '@services';
if (__DEV__) { if (__DEV__) {
require('./ReactotronConfig'); require('./ReactotronConfig');
} }
NotificationService.registerBackgroundHandler();
AppRegistry.registerComponent(appName, () => App); AppRegistry.registerComponent(appName, () => App);

View File

@ -11,6 +11,8 @@
13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 13B07FB51A68108700A75B9A /* Images.xcassets */; }; 13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 13B07FB51A68108700A75B9A /* Images.xcassets */; };
761780ED2CA45674006654EE /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 761780EC2CA45674006654EE /* AppDelegate.swift */; }; 761780ED2CA45674006654EE /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 761780EC2CA45674006654EE /* AppDelegate.swift */; };
81AB9BB82411601600AC10FF /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 81AB9BB72411601600AC10FF /* LaunchScreen.storyboard */; }; 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 */ /* End PBXBuildFile section */
/* Begin PBXFileReference 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>"; }; 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>"; }; 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; }; 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 */ /* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */ /* Begin PBXFrameworksBuildPhase section */
@ -46,6 +50,8 @@
13B07FB61A68108700A75B9A /* Info.plist */, 13B07FB61A68108700A75B9A /* Info.plist */,
81AB9BB72411601600AC10FF /* LaunchScreen.storyboard */, 81AB9BB72411601600AC10FF /* LaunchScreen.storyboard */,
13B07FB81A68108700A75B9A /* PrivacyInfo.xcprivacy */, 13B07FB81A68108700A75B9A /* PrivacyInfo.xcprivacy */,
688D1DB853AD445C91817BC9 /* BootSplash.storyboard */,
A57FEAAA1E5B410F8EEA9BA8 /* Colors.xcassets */,
); );
name = convex_CRM; name = convex_CRM;
sourceTree = "<group>"; sourceTree = "<group>";
@ -159,6 +165,8 @@
files = ( files = (
81AB9BB82411601600AC10FF /* LaunchScreen.storyboard in Resources */, 81AB9BB82411601600AC10FF /* LaunchScreen.storyboard in Resources */,
13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */, 13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */,
79E049D907A74F1AB0714099 /* BootSplash.storyboard in Resources */,
1E6477E483F84C6495D52EC7 /* Colors.xcassets in Resources */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };

View 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>

View File

@ -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
}
}

View 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
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Some files were not shown because too many files have changed in this diff Show More