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