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

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">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<application
android:name=".MainApplication"
@ -17,7 +17,8 @@
android:configChanges="keyboard|keyboardHidden|orientation|screenLayout|screenSize|smallestScreenSize|uiMode"
android:launchMode="singleTask"
android:windowSoftInputMode="adjustResize"
android:exported="true">
android:exported="true"
android:theme="@style/BootTheme">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />

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.ReactActivityDelegate
import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint.fabricEnabled
import com.facebook.react.defaults.DefaultReactActivityDelegate
import com.zoontek.rnbootsplash.RNBootSplash
class MainActivity : ReactActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
RNBootSplash.init(this, R.style.BootTheme)
super.onCreate(savedInstanceState)
}
/**
* Returns the name of the main component registered from JavaScript. This is used to schedule
* rendering of the component.

View File

@ -1,4 +1,4 @@
package com.convex_crm
package app.convexsol.convexsolcrm
import android.app.Application
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>
<!-- Base application theme. -->
<style name="AppTheme" parent="Theme.AppCompat.DayNight.NoActionBar">
<!-- Customize your theme here. -->
<item name="android:editTextBackground">@drawable/rn_edit_text_material</item>
</style>
<style name="BootTheme" parent="Theme.BootSplash">
<item name="bootSplashBackground">@color/bootsplash_background</item>
<item name="bootSplashLogo">@drawable/bootsplash_logo</item>
<item name="postBootSplashTheme">@style/AppTheme</item>
</style>
</resources>

View File

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

View File

@ -1,11 +1,13 @@
import React, { useEffect } from 'react';
import { StyleSheet, StatusBar } from 'react-native';
import { StatusBar } from 'react-native';
import { SafeAreaProvider } from 'react-native-safe-area-context';
import { RootNavigator } from './navigation/rootNavigator';
import { ThemeProvider, useTheme } from './theme';
import { Provider } from 'react-redux';
import { store, persistor } from '@store';
import { store, persistor, useAppDispatch, useAppSelector, getStatusList, getSourceList, getCountryList } from '@store';
import { PersistGate } from 'redux-persist/integration/react';
import { NotificationService } from '@services';
import BootSplash from 'react-native-bootsplash';
const ThemedStatusBar = () => {
const { theme: colors, isDark } = useTheme();
@ -23,13 +25,61 @@ const ThemedStatusBar = () => {
);
};
/** Dispatches startup data fetches once the Redux store is ready. */
const AppInit = () => {
const dispatch = useAppDispatch();
const token = useAppSelector(state => state.auth.token);
useEffect(() => {
if (token) {
dispatch(getStatusList());
dispatch(getSourceList());
dispatch(getCountryList());
}
}, [dispatch, token]);
return null;
};
const App = () => {
useEffect(() => {
let cleanup: (() => void) | undefined;
const initApp = async () => {
try {
const cleanupFn = await NotificationService.initialize({
onNotificationOpen: data => {
// TODO: Use data (e.g. { screen, id }) to deep-navigate once
console.log('[App] Notification opened with data:', data);
},
onTokenRefreshed: token => {
// TODO: Send the refreshed token to your backend so the server
// always has the latest FCM token for this device.
console.log('[App] FCM token refreshed — sync to server:', token);
},
});
cleanup = cleanupFn;
} catch (err) {
console.error('[App] NotificationService.initialize error:', err);
} finally {
await BootSplash.hide({ fade: true });
}
};
initApp();
return () => {
cleanup?.();
};
}, []);
return (
<Provider store={store}>
<PersistGate loading={null} persistor={persistor}>
<SafeAreaProvider>
<ThemeProvider>
<ThemedStatusBar />
<AppInit />
<RootNavigator />
</ThemeProvider>
</SafeAreaProvider>

View File

@ -1,3 +1,4 @@
export * from './authApi';
export * from './leadsApi';
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';
export const getLeadsApi = async (
@ -8,3 +8,10 @@ export const getLeadsApi = async (
const url = `/api/leads/${leadId}/${staffId}`;
return await api.get<LeadItem[]>(url);
};
export const getLeadCountListApi = async (
staffId: string,
): Promise<LeadCountItem[]> => {
const url = `/api/leadcountlist/${staffId}`;
return await api.get<LeadCountItem[]>(url);
};

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

View File

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

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'> {
label: string;
label?: string;
value: string;
onChangeText: (text: string) => void;
placeholder?: string;
required?: boolean;
multiline?: boolean;
leftIcon?: React.ReactNode;
rightIcon?: React.ReactNode;
containerStyle?: StyleProp<ViewStyle>;
inputContainerStyle?: StyleProp<ViewStyle>;
inputStyle?: StyleProp<TextStyle>;
}

View File

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

View File

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

View File

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

View File

@ -3,77 +3,98 @@ import { ThemeColors } from '../../theme';
export const getStyles = (colors: ThemeColors) =>
StyleSheet.create({
container: {
alignItems: 'center',
paddingVertical: 24,
paddingHorizontal: 16,
borderBottomWidth: 1,
borderBottomColor: colors.border,
card: {
backgroundColor: colors.card,
},
avatar: {
width: 80,
height: 80,
borderRadius: 40,
justifyContent: 'center',
borderRadius: 20,
padding: 20,
alignItems: 'center',
marginBottom: 16,
borderWidth: 1,
borderColor: colors.border,
shadowColor: '#5B4CF5',
shadowOffset: { width: 0, height: 6 },
shadowOpacity: 0.08,
shadowRadius: 16,
elevation: 4,
},
avatar: {
width: 76,
height: 76,
borderRadius: 38,
justifyContent: 'center',
alignItems: 'center',
marginBottom: 14,
},
avatarText: {
fontSize: 32,
fontWeight: '700',
fontSize: 26,
fontWeight: '800',
color: '#FFFFFF',
letterSpacing: 0.5,
},
nameRow: {
name: {
fontSize: 20,
fontWeight: '700',
color: colors.text,
textAlign: 'center',
marginBottom: 4,
},
companyRow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: 6,
marginBottom: 12,
flexWrap: 'wrap',
paddingHorizontal: 16,
},
name: {
fontSize: 24,
fontWeight: '700',
color: colors.text,
marginRight: 8,
},
statusBadge: {
paddingHorizontal: 12,
paddingVertical: 6,
borderRadius: 16,
},
statusText: {
fontSize: 13,
fontWeight: '600',
textTransform: 'capitalize',
},
company: {
fontSize: 16,
fontSize: 13,
color: colors.textSecondary,
marginBottom: 12,
textAlign: 'center',
fontWeight: '500',
},
contactRow: {
statusBadge: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: 8,
paddingHorizontal: 16,
gap: 6,
paddingHorizontal: 12,
paddingVertical: 5,
borderRadius: 20,
marginBottom: 16,
},
contactLabel: {
fontSize: 14,
statusDot: {
width: 6,
height: 6,
borderRadius: 3,
},
statusText: {
fontSize: 11,
fontWeight: '700',
textTransform: 'uppercase',
letterSpacing: 0.5,
},
quickActions: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: 12,
width: '100%',
paddingTop: 14,
borderTopWidth: StyleSheet.hairlineWidth,
borderTopColor: colors.border,
},
actionBtn: {
flex: 1,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: 6,
paddingVertical: 10,
borderRadius: 12,
borderWidth: 1,
borderColor: colors.border,
backgroundColor: colors.surface,
},
actionBtnText: {
fontSize: 12,
fontWeight: '600',
color: colors.text,
marginRight: 8,
minWidth: 50,
},
contactText: {
fontSize: 14,
color: colors.textSecondary,
flex: 1,
},
iconButton: {
marginLeft: 8,
padding: 4,
},
});

View File

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

View File

@ -3,72 +3,174 @@ import { ThemeColors } from '../../theme';
export const getStyles = (colors: ThemeColors) =>
StyleSheet.create({
cardWrapper: {
marginBottom: 16,
borderRadius: 20,
shadowColor: '#5B4CF5',
shadowOffset: { width: 0, height: 6 },
shadowOpacity: 0.10,
shadowRadius: 18,
elevation: 6,
},
card: {
backgroundColor: colors.card,
borderRadius: 12,
padding: 16,
marginBottom: 12,
borderRadius: 20,
flexDirection: 'row',
alignItems: 'center',
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.05,
shadowRadius: 4,
elevation: 2,
overflow: 'hidden',
borderWidth: 1,
borderColor: colors.border,
},
avatarContainer: {
width: 56,
height: 56,
borderRadius: 28,
leftStrip: {
width: 4,
borderTopLeftRadius: 20,
borderBottomLeftRadius: 20,
},
cardInner: {
flex: 1,
padding: 15,
paddingLeft: 14,
},
topSection: {
flexDirection: 'row',
alignItems: 'flex-start',
marginBottom: 12,
},
avatar: {
width: 48,
height: 48,
borderRadius: 24,
justifyContent: 'center',
alignItems: 'center',
marginRight: 12,
},
avatarText: {
fontSize: 20,
fontWeight: '700',
fontSize: 16,
fontWeight: '800',
color: '#FFFFFF',
letterSpacing: 0.5,
},
contentContainer: {
meta: {
flex: 1,
},
headerRow: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 8,
minWidth: 0,
},
name: {
fontSize: 16,
fontSize: 15,
fontWeight: '700',
color: colors.text,
flex: 1,
marginRight: 8,
marginBottom: 3,
letterSpacing: 0.05,
},
companyRow: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: 8,
gap: 5,
flexWrap: 'wrap',
},
companyText: {
fontSize: 12,
color: colors.textSecondary,
fontWeight: '500',
flexShrink: 1,
},
titleText: {
fontSize: 11,
color: colors.textMuted,
flexShrink: 1,
},
dot: {
width: 3,
height: 3,
borderRadius: 2,
backgroundColor: colors.textMuted,
opacity: 0.6,
},
statusBadge: {
alignSelf: 'flex-start',
flexDirection: 'row',
alignItems: 'center',
gap: 5,
paddingHorizontal: 10,
paddingVertical: 4,
borderRadius: 12,
flexShrink: 0,
borderRadius: 20,
},
statusDot: {
width: 5,
height: 5,
borderRadius: 3,
},
statusText: {
fontSize: 11,
fontWeight: '600',
textTransform: 'capitalize',
fontSize: 10,
fontWeight: '700',
textTransform: 'uppercase',
letterSpacing: 0.7,
},
separator: {
height: StyleSheet.hairlineWidth,
backgroundColor: colors.border,
marginVertical: 11,
},
contacts: {
gap: 8,
},
contactRow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: 6,
gap: 9,
},
iconWrap: {
width: 28,
height: 28,
borderRadius: 9,
justifyContent: 'center',
alignItems: 'center',
},
contactText: {
fontSize: 13,
fontSize: 12,
color: colors.textSecondary,
flex: 1,
marginRight: 8,
},
contactIconButton: {
padding: 4,
actionBtn: {
width: 28,
height: 28,
borderRadius: 9,
justifyContent: 'center',
alignItems: 'center',
},
footer: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
marginTop: 12,
paddingTop: 10,
borderTopWidth: StyleSheet.hairlineWidth,
borderTopColor: colors.border,
},
sourcePill: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: colors.surface,
borderRadius: 20,
paddingHorizontal: 9,
paddingVertical: 4,
gap: 5,
borderWidth: 1,
borderColor: colors.border,
},
sourceText: {
fontSize: 10,
color: colors.textMuted,
fontWeight: '600',
textTransform: 'uppercase',
letterSpacing: 0.5,
},
dateRow: {
flexDirection: 'row',
alignItems: 'center',
gap: 4,
},
dateText: {
fontSize: 10,
color: colors.textMuted,
},
});

View File

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

View File

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

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 {
View,
Text,
ScrollView,
Linking,
Platform,
} from 'react-native';
import { View, Text, ScrollView, TouchableOpacity } from 'react-native';
import Icon from 'react-native-vector-icons/Ionicons';
import { RouteProp, useRoute, useNavigation } from '@react-navigation/native';
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { useTheme } from '@theme';
@ -14,14 +9,10 @@ import { getLeadDetails } from './thunk';
import { getStyles } from './leadDetails.styles';
import { LeadsStackParamList } from '../../navigation/leadsStack';
import { Loader, LeadDetailHeader, ActionCard } from '@components';
import { handleEmailPress, handlePhonePress } from '@utils';
import { route as routes } from '@utils';
import { handleEmailPress, handlePhonePress, handleWebsitePress, formatDate, route as routes } from '@utils';
type LeadDetailsRouteProp = RouteProp<LeadsStackParamList, 'leadDetails'>;
type LeadDetailsNavigationProp = NativeStackNavigationProp<
LeadsStackParamList,
'leadDetails'
>;
type LeadDetailsNavigationProp = NativeStackNavigationProp<LeadsStackParamList, 'leadDetails'>;
export const LeadDetailsScreen = () => {
const route = useRoute<LeadDetailsRouteProp>();
@ -30,35 +21,23 @@ export const LeadDetailsScreen = () => {
const { theme: colors } = useTheme();
const styles = getStyles(colors);
// Get lead ID from route params
const leadId = route.params?.lead?.id;
// Get data from Redux store
const { item: lead, loading, error } = useAppSelector(
(state: RootState) => state.leadDetails,
);
const { item: lead, loading, error } = useAppSelector((state: RootState) => state.leadDetails);
const user_data = useAppSelector((state: RootState) => state.auth.user_data);
// Fetch lead details on mount
useEffect(() => {
if (leadId && user_data?.staffid) {
dispatch(getLeadDetails({ leadId, staffid: user_data.staffid }));
}
}, [dispatch, leadId, user_data]);
// Show loader while loading
if (loading) {
return <Loader message="Loading lead details..." />;
}
if (loading) return <Loader message="Loading lead details..." />;
// Show error if any
if (error) {
return (
<View style={styles.container}>
<View style={{ flex: 1, justifyContent: 'center', alignItems: 'center', padding: 32 }}>
<Text style={{ color: '#EF4444', fontSize: 14, textAlign: 'center' }}>
{error}
</Text>
<View style={styles.errorContainer}>
<Text style={styles.errorText}>{error}</Text>
</View>
</View>
);
@ -67,50 +46,19 @@ export const LeadDetailsScreen = () => {
if (!lead) {
return (
<View style={styles.container}>
<View style={styles.errorContainer}>
<Text style={styles.infoValue}>No lead data available</Text>
</View>
</View>
);
}
// Handle status press (for future status change functionality)
const handleStatusPress = () => {
// TODO: Show status list modal/picker
console.log('Status pressed - show status list');
};
// Proposals handlers
const handleAddProposal = () => {
// TODO: Navigate to add proposal screen
navigation.navigate(routes.leadProposals, { leadId: lead.id });
};
const handleViewAllProposals = () => {
console.log('view Proposal pressed');
};
// Tasks handlers
const handleAddTask = () => {
// TODO: Navigate to add task screen
navigation.navigate(routes.leadTasks, { leadId: lead.id });
};
const handleViewAllTasks = () => {
console.log('view Add Task pressed');
};
// Notes handlers
const handleAddNote = () => {
// TODO: Navigate to add note screen
navigation.navigate(routes.leadNotes, { leadId: lead.id });
};
const handleViewAllNotes = () => {
console.log('view Add Note pressed');
};
const addressString = [lead.address, lead.city, lead.state, lead.country]
.filter(Boolean)
.join(', ');
return (
<ScrollView style={styles.container} contentContainerStyle={styles.scrollContent}>
{/* Header with Avatar */}
<LeadDetailHeader
name={lead.name}
company={lead.company}
@ -118,35 +66,139 @@ export const LeadDetailsScreen = () => {
phonenumber={lead.phonenumber}
statusName={lead.status_name || lead.status}
statusColor={lead.color}
onStatusPress={handleStatusPress}
onEmailPress={() => handleEmailPress(lead.email)}
onPhonePress={() => handlePhonePress(lead.phonenumber)}
/>
{/* Action Cards Section */}
<View style={styles.statsRow}>
<View style={styles.statCard}>
<Text style={styles.statLabel}>Lead Value</Text>
<Text style={styles.statValue}>
{lead.lead_value ? `$${lead.lead_value}` : 'N/A'}
</Text>
</View>
<View style={styles.statCard}>
<Text style={styles.statLabel}>Source</Text>
<Text style={styles.statValue} numberOfLines={1}>
{lead.source_name || lead.source || 'Direct'}
</Text>
</View>
<View style={styles.statCard}>
<Text style={styles.statLabel}>Created</Text>
<Text style={styles.statValue} numberOfLines={1}>
{formatDate(lead.dateadded) || 'N/A'}
</Text>
</View>
</View>
<Text style={styles.sectionTitle}>Lead Information</Text>
<View style={styles.infoCard}>
{lead.email ? (
<>
<TouchableOpacity
style={styles.infoRow}
onPress={() => handleEmailPress(lead.email)}
activeOpacity={0.7}>
<View style={styles.infoIconWrap}>
<Icon name="mail-outline" size={15} color={colors.icon} />
</View>
<View style={styles.infoMeta}>
<Text style={styles.infoLabel}>Email</Text>
<Text style={styles.infoValue}>{lead.email}</Text>
</View>
</TouchableOpacity>
<View style={styles.divider} />
</>
) : null}
{lead.phonenumber ? (
<>
<TouchableOpacity
style={styles.infoRow}
onPress={() => handlePhonePress(lead.phonenumber)}
activeOpacity={0.7}>
<View style={styles.infoIconWrap}>
<Icon name="call-outline" size={15} color={colors.icon} />
</View>
<View style={styles.infoMeta}>
<Text style={styles.infoLabel}>Phone</Text>
<Text style={styles.infoValue}>{lead.phonenumber}</Text>
</View>
</TouchableOpacity>
<View style={styles.divider} />
</>
) : null}
{lead.website ? (
<>
<TouchableOpacity
style={styles.infoRow}
onPress={() => handleWebsitePress(lead.website)}
activeOpacity={0.7}>
<View style={styles.infoIconWrap}>
<Icon name="globe-outline" size={15} color={colors.icon} />
</View>
<View style={styles.infoMeta}>
<Text style={styles.infoLabel}>Website</Text>
<Text style={styles.infoValue}>{lead.website}</Text>
</View>
</TouchableOpacity>
<View style={styles.divider} />
</>
) : null}
{addressString ? (
<>
<View style={styles.infoRow}>
<View style={styles.infoIconWrap}>
<Icon name="location-outline" size={15} color={colors.icon} />
</View>
<View style={styles.infoMeta}>
<Text style={styles.infoLabel}>Address</Text>
<Text style={styles.infoValue}>{addressString}</Text>
</View>
</View>
<View style={styles.divider} />
</>
) : null}
{lead.assigned ? (
<View style={styles.infoRow}>
<View style={styles.infoIconWrap}>
<Icon name="person-outline" size={15} color={colors.icon} />
</View>
<View style={styles.infoMeta}>
<Text style={styles.infoLabel}>Assigned Staff</Text>
<Text style={styles.infoValue}>{lead.assigned}</Text>
</View>
</View>
) : null}
</View>
<Text style={styles.sectionTitle}>Activity & Management</Text>
<View style={styles.cardsContainer}>
<ActionCard
title="Proposals"
icon="document-text-outline"
count={0}
onAddPress={handleAddProposal}
onViewAllPress={handleViewAllProposals}
onAddPress={() => navigation.navigate(routes.leadProposals, { leadId: lead.id })}
onViewAllPress={() => navigation.navigate(routes.leadProposals, { leadId: lead.id })}
/>
<ActionCard
title="Tasks"
icon="checkbox-outline"
count={0}
onAddPress={handleAddTask}
onViewAllPress={handleViewAllTasks}
onAddPress={() => navigation.navigate(routes.leadTasks, { leadId: lead.id })}
onViewAllPress={() => navigation.navigate(routes.leadTasks, { leadId: lead.id })}
/>
<ActionCard
title="Notes"
icon="create-outline"
count={0}
onAddPress={handleAddNote}
onViewAllPress={handleViewAllNotes}
onAddPress={() => navigation.navigate(routes.leadNotes, { leadId: lead.id })}
onViewAllPress={() => navigation.navigate(routes.leadNotes, { leadId: lead.id })}
/>
</View>
</ScrollView>

View File

@ -9,114 +9,102 @@ export const getStyles = (colors: ThemeColors) =>
},
scrollContent: {
padding: 16,
},
cardsContainer: {
marginTop: 16,
},
header: {
alignItems: 'center',
paddingVertical: 24,
borderBottomWidth: 1,
borderBottomColor: colors.border,
marginBottom: 16,
},
avatar: {
width: 80,
height: 80,
borderRadius: 40,
justifyContent: 'center',
alignItems: 'center',
marginBottom: 12,
},
avatarText: {
fontSize: 32,
fontWeight: '700',
color: '#FFFFFF',
},
name: {
fontSize: 24,
fontWeight: '700',
color: colors.text,
marginBottom: 4,
},
company: {
fontSize: 16,
color: colors.textSecondary,
marginBottom: 8,
},
statusBadge: {
paddingHorizontal: 16,
paddingVertical: 6,
borderRadius: 16,
},
statusText: {
fontSize: 13,
fontWeight: '600',
textTransform: 'capitalize',
},
section: {
backgroundColor: colors.card,
borderRadius: 12,
padding: 16,
marginBottom: 16,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.05,
shadowRadius: 4,
elevation: 2,
paddingBottom: 32,
},
sectionTitle: {
fontSize: 16,
fontSize: 15,
fontWeight: '700',
color: colors.text,
marginBottom: 12,
letterSpacing: 0.1,
},
statsRow: {
flexDirection: 'row',
gap: 10,
marginBottom: 16,
},
statCard: {
flex: 1,
backgroundColor: colors.card,
borderRadius: 16,
padding: 14,
borderWidth: 1,
borderColor: colors.border,
shadowColor: '#5B4CF5',
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.05,
shadowRadius: 10,
elevation: 2,
},
statLabel: {
fontSize: 11,
color: colors.textMuted,
marginBottom: 4,
fontWeight: '500',
},
statValue: {
fontSize: 15,
fontWeight: '700',
color: colors.text,
},
infoCard: {
backgroundColor: colors.card,
borderRadius: 18,
padding: 16,
marginBottom: 16,
borderWidth: 1,
borderColor: colors.border,
shadowColor: '#5B4CF5',
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.05,
shadowRadius: 10,
elevation: 2,
},
infoRow: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: 12,
paddingVertical: 9,
gap: 12,
},
infoIcon: {
width: 40,
height: 40,
borderRadius: 20,
infoIconWrap: {
width: 32,
height: 32,
borderRadius: 10,
backgroundColor: colors.surface,
justifyContent: 'center',
alignItems: 'center',
marginRight: 12,
borderWidth: 1,
borderColor: colors.border,
},
infoContent: {
infoMeta: {
flex: 1,
},
infoLabel: {
fontSize: 12,
fontSize: 11,
color: colors.textMuted,
marginBottom: 2,
},
infoValue: {
fontSize: 14,
color: colors.text,
fontWeight: '500',
},
actionButton: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: colors.icon,
paddingVertical: 14,
paddingHorizontal: 20,
borderRadius: 12,
marginBottom: 12,
},
actionButtonText: {
fontSize: 15,
fontSize: 13,
fontWeight: '600',
color: '#FFFFFF',
marginLeft: 8,
color: colors.text,
},
noteText: {
divider: {
height: StyleSheet.hairlineWidth,
backgroundColor: colors.border,
},
cardsContainer: {
marginTop: 4,
},
errorContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
padding: 32,
},
errorText: {
color: '#EF4444',
fontSize: 14,
color: colors.textSecondary,
lineHeight: 20,
textAlign: 'center',
},
});

View File

@ -3,7 +3,9 @@ import {
Text,
View,
FlatList,
ScrollView,
TouchableOpacity,
ActivityIndicator,
} from 'react-native';
import { useNavigation } from '@react-navigation/native';
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
@ -11,9 +13,9 @@ import Icon from 'react-native-vector-icons/Ionicons';
import { getStyles } from './leads.styles';
import { useTheme } from '@theme';
import { useAppDispatch, useAppSelector, RootState } from '@store';
import { getLeads } from './thunk';
import { getLeads, getLeadCountList } from './thunk';
import { LeadItem } from '@interfaces';
import { LeadItemCard, SearchInput, Loader } from '@components';
import { LeadItemCard, SearchInput, Loader, StatCard } from '@components';
import { LeadsStackParamList } from '../../navigation/leadsStack';
import { route } from '@utils';
@ -28,8 +30,9 @@ export const LeadsScreen = () => {
const { theme: colors } = useTheme();
const styles = getStyles(colors);
const [searchTerm, setSearchTerm] = useState('');
const [selectedStatusId, setSelectedStatusId] = useState<string | null>(null);
const { items, loading, error } = useAppSelector(
const { items, loading, error, counts, countsLoading } = useAppSelector(
(state: RootState) => state.leads,
);
const user_data = useAppSelector((state: RootState) => state.auth.user_data);
@ -37,20 +40,30 @@ export const LeadsScreen = () => {
useEffect(() => {
if (user_data?.staffid) {
dispatch(getLeads({ leadId: null, staffid: user_data.staffid }));
dispatch(getLeadCountList(user_data.staffid));
}
}, [dispatch, user_data]);
const filteredLeads = searchTerm
? items.filter(
lead =>
lead.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
const filteredLeads = items.filter(lead => {
const matchesSearch = searchTerm
? lead.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
lead.company.toLowerCase().includes(searchTerm.toLowerCase()) ||
lead.email.toLowerCase().includes(searchTerm.toLowerCase()),
)
: items;
lead.email.toLowerCase().includes(searchTerm.toLowerCase())
: true;
const matchesStatus =
!selectedStatusId || selectedStatusId === '0'
? true
: lead.status === selectedStatusId;
return matchesSearch && matchesStatus;
});
const handleStatusPress = (id: string) => {
setSelectedStatusId(prev => (prev === id ? null : id));
};
const handleLeadPress = (lead: LeadItem) => {
// Navigate to lead details screen
navigation.navigate(route.leadDetails, { lead });
};
@ -60,6 +73,7 @@ export const LeadsScreen = () => {
return (
<View style={styles.container}>
{/* Search + Filter Header (Fixed at top) */}
<View style={styles.header}>
<SearchInput
value={searchTerm}
@ -72,6 +86,32 @@ export const LeadsScreen = () => {
</TouchableOpacity>
</View>
{/* Status count cards */}
{countsLoading ? (
<View style={styles.countsLoading}>
<ActivityIndicator size="small" color={colors.icon} />
</View>
) : counts.length > 0 ? (
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
style={{ flexGrow: 0 }}
contentContainerStyle={styles.countsScroll}
>
{counts.map(item => (
<StatCard
key={item.id}
label={item.name}
count={item.count}
color={item.color || colors.icon}
isSelected={selectedStatusId === item.id}
onPress={() => handleStatusPress(item.id)}
/>
))}
</ScrollView>
) : null}
{/* Lead list */}
{loading ? (
<Loader message="Loading leads..." />
) : error ? (
@ -82,11 +122,20 @@ export const LeadsScreen = () => {
<FlatList
data={filteredLeads}
keyExtractor={item => item.id}
style={{ flex: 1 }}
contentContainerStyle={styles.listContent}
renderItem={renderLead}
ListEmptyComponent={() => (
<View style={styles.emptyContainer}>
<Text style={styles.emptyText}>No leads available.</Text>
<View style={styles.emptyIconWrap}>
<Icon name="people-outline" size={32} color={colors.icon} />
</View>
<Text style={styles.emptyTitle}>No Leads Found</Text>
<Text style={styles.emptyText}>
{searchTerm
? `No leads match "${searchTerm}". Try a different search.`
: 'You have no leads yet. New leads will appear here.'}
</Text>
</View>
)}
/>

View File

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

View File

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

View File

@ -1,6 +1,6 @@
import { createAsyncThunk } from '@reduxjs/toolkit';
import { getLeadsApi } from '@api';
import { LeadItem } from '@interfaces';
import { getLeadsApi, getLeadCountListApi } from '@api';
import { LeadItem, LeadCountItem } from '@interfaces';
export const getLeads = createAsyncThunk<
LeadItem[],
@ -12,3 +12,14 @@ export const getLeads = createAsyncThunk<
return rejectWithValue(error.message || 'Failed to load leads');
}
});
export const getLeadCountList = createAsyncThunk<
LeadCountItem[],
string
>('leads/getLeadCountList', async (staffid, { rejectWithValue }) => {
try {
return await getLeadCountListApi(staffid);
} catch (error: any) {
return rejectWithValue(error.message || 'Failed to load lead counts');
}
});

View File

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

View File

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

View File

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

View File

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

View File

@ -46,3 +46,12 @@ export interface LeadItem {
status_name: string;
source_name: string;
}
export interface LeadCountItem {
id: string;
name: string;
statusorder: string | number;
color: string;
isdefault: string | number;
count: number;
}

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

View File

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

View File

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

View File

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

View File

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

View File

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

1
app/services/index.ts Normal file
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,
};
export const reducers = createReducer(initialState, builder => {
export const authReducer = createReducer(initialState, builder => {
builder
.addCase(login.pending, acc => {
acc.loginLoading = true;
@ -49,4 +49,4 @@ export const reducers = createReducer(initialState, builder => {
});
});
export default reducers;
export default authReducer;

View File

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

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 './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 authReducer from './commonReducers/auth/reducers';
import statusListReducer from './commonReducers/statuslist/reducers';
import sourceListReducer from './commonReducers/sourcelist/reducers';
import countryListReducer from './commonReducers/countrylist/reducers';
import leadsReducer from '../features/leads/reducers';
import leadDetailsReducer from '../features/leadDetails/reducers';
const rootReducer = combineReducers({
const appReducer = combineReducers({
auth: authReducer,
statusList: statusListReducer,
sourceList: sourceListReducer,
countryList: countryListReducer,
leads: leadsReducer,
leadDetails: leadDetailsReducer,
});
export type RootState = ReturnType<typeof rootReducer>;
const rootReducer = (state: any, action: any) => {
if (action.type === 'USER_LOGOUT') {
state = undefined;
}
return appReducer(state, action);
};
export type RootState = ReturnType<typeof appReducer>;
export default rootReducer;

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

View File

@ -1,2 +1,3 @@
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();
};
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 App from './app/App';
import { name as appName } from './app.json';
import { NotificationService } from '@services';
if (__DEV__) {
require('./ReactotronConfig');
}
NotificationService.registerBackgroundHandler();
AppRegistry.registerComponent(appName, () => App);

View File

@ -11,6 +11,8 @@
13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 13B07FB51A68108700A75B9A /* Images.xcassets */; };
761780ED2CA45674006654EE /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 761780EC2CA45674006654EE /* AppDelegate.swift */; };
81AB9BB82411601600AC10FF /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 81AB9BB72411601600AC10FF /* LaunchScreen.storyboard */; };
79E049D907A74F1AB0714099 /* BootSplash.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 688D1DB853AD445C91817BC9 /* BootSplash.storyboard */; };
1E6477E483F84C6495D52EC7 /* Colors.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A57FEAAA1E5B410F8EEA9BA8 /* Colors.xcassets */; };
/* End PBXBuildFile section */
/* Begin PBXFileReference section */
@ -24,6 +26,8 @@
761780EC2CA45674006654EE /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = AppDelegate.swift; path = convex_CRM/AppDelegate.swift; sourceTree = "<group>"; };
81AB9BB72411601600AC10FF /* LaunchScreen.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; name = LaunchScreen.storyboard; path = convex_CRM/LaunchScreen.storyboard; sourceTree = "<group>"; };
ED297162215061F000B7C4FE /* JavaScriptCore.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = JavaScriptCore.framework; path = System/Library/Frameworks/JavaScriptCore.framework; sourceTree = SDKROOT; };
688D1DB853AD445C91817BC9 /* BootSplash.storyboard */ = {isa = PBXFileReference; name = "BootSplash.storyboard"; path = "convex_CRM/BootSplash.storyboard"; sourceTree = "<group>"; fileEncoding = undefined; lastKnownFileType = unknown; explicitFileType = undefined; includeInIndex = 0; };
A57FEAAA1E5B410F8EEA9BA8 /* Colors.xcassets */ = {isa = PBXFileReference; name = "Colors.xcassets"; path = "convex_CRM/Colors.xcassets"; sourceTree = "<group>"; fileEncoding = undefined; lastKnownFileType = folder.assetcatalog; explicitFileType = undefined; includeInIndex = 0; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
@ -46,6 +50,8 @@
13B07FB61A68108700A75B9A /* Info.plist */,
81AB9BB72411601600AC10FF /* LaunchScreen.storyboard */,
13B07FB81A68108700A75B9A /* PrivacyInfo.xcprivacy */,
688D1DB853AD445C91817BC9 /* BootSplash.storyboard */,
A57FEAAA1E5B410F8EEA9BA8 /* Colors.xcassets */,
);
name = convex_CRM;
sourceTree = "<group>";
@ -159,6 +165,8 @@
files = (
81AB9BB82411601600AC10FF /* LaunchScreen.storyboard in Resources */,
13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */,
79E049D907A74F1AB0714099 /* BootSplash.storyboard in Resources */,
1E6477E483F84C6495D52EC7 /* Colors.xcassets in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};

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