sales force integrated
This commit is contained in:
parent
536a72ff4a
commit
af8cc5d52d
314
SALESFORCE_BACKEND_CALLBACK_UPDATE.md
Normal file
314
SALESFORCE_BACKEND_CALLBACK_UPDATE.md
Normal file
@ -0,0 +1,314 @@
|
|||||||
|
# 🔄 Salesforce OAuth - Backend Callback Flow Update
|
||||||
|
|
||||||
|
## What Changed?
|
||||||
|
|
||||||
|
The Salesforce OAuth implementation has been updated to use a **backend callback flow** instead of a mobile deep-link scheme. This means the backend server handles the OAuth callback and token exchange directly.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Previous Flow vs New Flow
|
||||||
|
|
||||||
|
### ❌ Previous Flow (Deep Link)
|
||||||
|
```
|
||||||
|
Salesforce → Mobile App (deep link) → Send code to backend → Backend exchanges token
|
||||||
|
```
|
||||||
|
- **Redirect URI**: `centralizedreportingsystem://oauth/salesforce/callback`
|
||||||
|
- **Problem**: Custom scheme requires complex deep linking configuration
|
||||||
|
- **Risk**: Authorization code exposed to mobile app
|
||||||
|
|
||||||
|
### ✅ New Flow (Backend Callback)
|
||||||
|
```
|
||||||
|
Salesforce → Backend Server → Exchange token → Success page → Mobile app detects success
|
||||||
|
```
|
||||||
|
- **Redirect URI**: `https://YOUR_BACKEND_URL/api/v1/users/oauth/callback?user_uuid=USER_ID&service_name=salesforce`
|
||||||
|
- **Benefits**:
|
||||||
|
- Backend handles token exchange securely
|
||||||
|
- No need for deep linking configuration
|
||||||
|
- Authorization code never exposed to mobile app
|
||||||
|
- Standard OAuth 2.0 web flow
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Code Changes
|
||||||
|
|
||||||
|
### SalesforceAuth.tsx
|
||||||
|
|
||||||
|
#### 1. Updated Configuration
|
||||||
|
```typescript
|
||||||
|
// OLD
|
||||||
|
const SALESFORCE_CONFIG = {
|
||||||
|
CLIENT_ID: 'YOUR_CLIENT_ID',
|
||||||
|
REDIRECT_URI: 'centralizedreportingsystem://oauth/salesforce/callback',
|
||||||
|
AUTH_BASE_URL: 'https://login.salesforce.com',
|
||||||
|
};
|
||||||
|
|
||||||
|
// NEW
|
||||||
|
const SALESFORCE_CONFIG = {
|
||||||
|
CLIENT_ID: 'YOUR_CLIENT_ID',
|
||||||
|
BACKEND_BASE_URL: 'https://d5285bf63993.ngrok-free.app',
|
||||||
|
CALLBACK_PATH: '/api/v1/users/oauth/callback',
|
||||||
|
AUTH_BASE_URL: 'https://login.salesforce.com',
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2. Updated OAuth URL Builder
|
||||||
|
```typescript
|
||||||
|
// OLD
|
||||||
|
const buildSalesforceAuthUrl = (): string => {
|
||||||
|
const redirectUri = 'centralizedreportingsystem://oauth/salesforce/callback';
|
||||||
|
// ...
|
||||||
|
};
|
||||||
|
|
||||||
|
// NEW
|
||||||
|
const buildSalesforceAuthUrl = (userUuid: string): string => {
|
||||||
|
// Build redirect URI with query parameters for backend
|
||||||
|
const redirectUri = `${BACKEND_BASE_URL}${CALLBACK_PATH}?user_uuid=${userUuid}&service_name=salesforce`;
|
||||||
|
// ...
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3. New Detection Functions
|
||||||
|
```typescript
|
||||||
|
// Check if URL is the backend callback
|
||||||
|
const isBackendCallbackUri = (url: string): boolean => {
|
||||||
|
return url.includes('/api/v1/users/oauth/callback');
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check for success
|
||||||
|
const isCallbackSuccess = (url: string): boolean => {
|
||||||
|
const status = getQueryParamFromUrl(url, 'status');
|
||||||
|
return status === 'success';
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check for error
|
||||||
|
const isCallbackError = (url: string): boolean => {
|
||||||
|
const status = getQueryParamFromUrl(url, 'status');
|
||||||
|
return status === 'error' || status === 'failure';
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 4. Simplified Navigation Handler
|
||||||
|
```typescript
|
||||||
|
// OLD - Mobile app handled token exchange
|
||||||
|
handleNavigationStateChange = (navState) => {
|
||||||
|
if (isRedirectUri(url) && authCode) {
|
||||||
|
handleAuthorizationCode(authCode); // Send code to backend
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// NEW - Backend handles token exchange
|
||||||
|
handleNavigationStateChange = (navState) => {
|
||||||
|
if (isBackendCallbackUri(url)) {
|
||||||
|
if (isCallbackSuccess(url)) {
|
||||||
|
handleBackendSuccess(); // Just close modal
|
||||||
|
}
|
||||||
|
if (isCallbackError(url)) {
|
||||||
|
handleBackendError(url); // Show error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Backend Implementation Required
|
||||||
|
|
||||||
|
### Endpoint: `GET /api/v1/users/oauth/callback`
|
||||||
|
|
||||||
|
Your backend **must** implement this endpoint to handle the OAuth callback from Salesforce.
|
||||||
|
|
||||||
|
#### Query Parameters (Received from Salesforce):
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
user_uuid: string; // User ID (passed in redirect_uri)
|
||||||
|
service_name: string; // 'salesforce' (passed in redirect_uri)
|
||||||
|
code: string; // Authorization code from Salesforce
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Backend Responsibilities:
|
||||||
|
|
||||||
|
1. **Receive the callback** from Salesforce
|
||||||
|
2. **Extract** query parameters: `user_uuid`, `service_name`, `code`
|
||||||
|
3. **Exchange** authorization code for tokens with Salesforce:
|
||||||
|
```http
|
||||||
|
POST https://login.salesforce.com/services/oauth2/token
|
||||||
|
grant_type=authorization_code
|
||||||
|
code={CODE}
|
||||||
|
client_id={CLIENT_ID}
|
||||||
|
client_secret={CLIENT_SECRET}
|
||||||
|
redirect_uri={CALLBACK_URL_WITH_PARAMS}
|
||||||
|
```
|
||||||
|
4. **Store** tokens (encrypted) in database
|
||||||
|
5. **Return** success/error HTML page:
|
||||||
|
- Success: Redirect to `?status=success`
|
||||||
|
- Error: Redirect to `?status=error&message=ERROR_MESSAGE`
|
||||||
|
|
||||||
|
#### Example Response (Success):
|
||||||
|
```html
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head><title>Success</title></head>
|
||||||
|
<body>
|
||||||
|
<h1>✓ Authentication Successful!</h1>
|
||||||
|
<p>Your Salesforce account has been connected.</p>
|
||||||
|
<script>
|
||||||
|
// Mobile app will detect this URL
|
||||||
|
window.location.search = '?status=success';
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
```
|
||||||
|
|
||||||
|
See [SALESFORCE_BACKEND_CALLBACK_FLOW.md](src/modules/integrations/screens/SALESFORCE_BACKEND_CALLBACK_FLOW.md) for complete backend implementation guide.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Configuration Steps
|
||||||
|
|
||||||
|
### 1. Update Salesforce Connected App
|
||||||
|
|
||||||
|
In your Salesforce Connected App settings:
|
||||||
|
|
||||||
|
**Old Callback URL**:
|
||||||
|
```
|
||||||
|
centralizedreportingsystem://oauth/salesforce/callback
|
||||||
|
```
|
||||||
|
|
||||||
|
**New Callback URL**:
|
||||||
|
```
|
||||||
|
https://YOUR_BACKEND_URL/api/v1/users/oauth/callback
|
||||||
|
```
|
||||||
|
|
||||||
|
> Note: Don't include query parameters in the Salesforce Connected App config. The app adds them dynamically.
|
||||||
|
|
||||||
|
### 2. Update Mobile App (Already Done ✅)
|
||||||
|
|
||||||
|
The `SalesforceAuth.tsx` component has been updated with:
|
||||||
|
- ✅ New backend callback URL configuration
|
||||||
|
- ✅ Detection logic for backend callback
|
||||||
|
- ✅ Success/error handling
|
||||||
|
- ✅ User UUID passed to backend
|
||||||
|
|
||||||
|
### 3. Configure Backend
|
||||||
|
|
||||||
|
Update your environment variables:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# .env
|
||||||
|
SALESFORCE_CLIENT_ID=3MVG9GBhY6wQjl2sueQtv2NXMm3EuWtEvOQoeKRAzYcgs2...
|
||||||
|
SALESFORCE_CLIENT_SECRET=XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
|
||||||
|
BACKEND_URL=https://d5285bf63993.ngrok-free.app
|
||||||
|
ENCRYPTION_KEY=0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Implement Backend Endpoint
|
||||||
|
|
||||||
|
Create the `/api/v1/users/oauth/callback` endpoint following the guide in [SALESFORCE_BACKEND_CALLBACK_FLOW.md](src/modules/integrations/screens/SALESFORCE_BACKEND_CALLBACK_FLOW.md).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Testing the Updated Flow
|
||||||
|
|
||||||
|
### Test Checklist:
|
||||||
|
|
||||||
|
1. **Start Backend Server**
|
||||||
|
- Ensure backend is running
|
||||||
|
- Verify callback endpoint is accessible
|
||||||
|
- For local testing, use ngrok: `ngrok http 4000`
|
||||||
|
|
||||||
|
2. **Update Configuration**
|
||||||
|
- Update `BACKEND_BASE_URL` in `SalesforceAuth.tsx`
|
||||||
|
- Update Salesforce Connected App callback URL
|
||||||
|
- Restart mobile app
|
||||||
|
|
||||||
|
3. **Test Authentication**
|
||||||
|
- Open app → CRM & Sales → Salesforce
|
||||||
|
- Complete Salesforce login
|
||||||
|
- **Expected**: Redirect to backend URL
|
||||||
|
- **Backend logs should show**: "Received callback", "Token exchange successful", "Tokens stored"
|
||||||
|
- **Mobile app should show**: Success message, then close modal
|
||||||
|
|
||||||
|
4. **Verify Token Storage**
|
||||||
|
- Check database for stored tokens
|
||||||
|
- Verify tokens are encrypted
|
||||||
|
- Verify associated with correct user_id
|
||||||
|
|
||||||
|
5. **Test Re-authentication**
|
||||||
|
- Tap "Re-auth" button
|
||||||
|
- Complete flow again
|
||||||
|
- Verify tokens are updated in database
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Debugging
|
||||||
|
|
||||||
|
### Check Logs
|
||||||
|
|
||||||
|
#### Mobile App (React Native)
|
||||||
|
```
|
||||||
|
[SalesforceAuth] Built OAuth URL: https://login.salesforce.com/...
|
||||||
|
[SalesforceAuth] Redirect URI: https://YOUR_BACKEND/callback?user_uuid=...
|
||||||
|
[SalesforceAuth] Backend callback detected
|
||||||
|
[SalesforceAuth] Backend callback indicates success
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Backend Server
|
||||||
|
```
|
||||||
|
[OAuth Callback] Received: { user_uuid: '...', service_name: 'salesforce', hasCode: true }
|
||||||
|
[OAuth Callback] Token exchange successful
|
||||||
|
[OAuth Callback] Tokens stored successfully
|
||||||
|
```
|
||||||
|
|
||||||
|
### Common Issues
|
||||||
|
|
||||||
|
| Issue | Solution |
|
||||||
|
|-------|----------|
|
||||||
|
| Backend not reachable | Use ngrok for local testing: `ngrok http 4000` |
|
||||||
|
| Callback URL mismatch | Update Salesforce Connected App with exact backend URL |
|
||||||
|
| Token exchange fails | Verify CLIENT_SECRET is correct in backend |
|
||||||
|
| Mobile app doesn't detect success | Ensure backend returns HTML with `?status=success` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Benefits of Backend Callback Flow
|
||||||
|
|
||||||
|
✅ **More Secure**: Authorization code never exposed to mobile app
|
||||||
|
✅ **Simpler Configuration**: No deep linking setup needed
|
||||||
|
✅ **Standard OAuth**: Follows standard web OAuth 2.0 flow
|
||||||
|
✅ **Backend Control**: Full control over token exchange and storage
|
||||||
|
✅ **Better Error Handling**: Backend can provide detailed error messages
|
||||||
|
✅ **Token Refresh**: Backend can implement automatic token refresh
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Migration Checklist
|
||||||
|
|
||||||
|
- [x] Update `SalesforceAuth.tsx` configuration
|
||||||
|
- [x] Update OAuth URL builder to include user_uuid
|
||||||
|
- [x] Add backend callback detection logic
|
||||||
|
- [x] Simplify navigation handler
|
||||||
|
- [ ] **Implement backend `/oauth/callback` endpoint**
|
||||||
|
- [ ] **Update Salesforce Connected App callback URL**
|
||||||
|
- [ ] **Configure backend environment variables**
|
||||||
|
- [ ] **Test end-to-end flow**
|
||||||
|
- [ ] **Verify token storage in database**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Next Steps
|
||||||
|
|
||||||
|
1. **Implement Backend Endpoint**: Follow the guide in [SALESFORCE_BACKEND_CALLBACK_FLOW.md](src/modules/integrations/screens/SALESFORCE_BACKEND_CALLBACK_FLOW.md)
|
||||||
|
|
||||||
|
2. **Update Salesforce Config**: Change callback URL in Connected App settings
|
||||||
|
|
||||||
|
3. **Test Thoroughly**: Test authentication flow with both success and error scenarios
|
||||||
|
|
||||||
|
4. **Deploy**: Deploy backend changes and update mobile app configuration
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Updated**: October 2025
|
||||||
|
**Status**: ✅ Mobile App Updated - Backend Implementation Required
|
||||||
|
**Version**: 2.0.0
|
||||||
|
|
||||||
380
SALESFORCE_INTEGRATION_SUMMARY.md
Normal file
380
SALESFORCE_INTEGRATION_SUMMARY.md
Normal file
@ -0,0 +1,380 @@
|
|||||||
|
# 🎉 Salesforce Authentication Integration - Implementation Summary
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
Successfully implemented Salesforce OAuth authentication for the Centralized Reporting System, enabling users to authenticate with Salesforce through the CRM & Sales Integration category.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ What Was Implemented
|
||||||
|
|
||||||
|
### 1. **SalesforceAuth Component** (`src/modules/integrations/screens/SalesforceAuth.tsx`)
|
||||||
|
- Full OAuth 2.0 authentication flow using WebView
|
||||||
|
- Mobile-optimized with `display=touch` parameter
|
||||||
|
- Authorization code capture and exchange
|
||||||
|
- Comprehensive error handling
|
||||||
|
- Loading states with processing modal
|
||||||
|
- Deep linking support for OAuth callback
|
||||||
|
- Similar architecture to ZohoAuth for consistency
|
||||||
|
|
||||||
|
### 2. **Updated IntegrationCategoryScreen** (`src/modules/integrations/screens/IntegrationCategoryScreen.tsx`)
|
||||||
|
- Added Salesforce authentication support
|
||||||
|
- Added `checkSalesforceToken()` function to verify existing tokens
|
||||||
|
- Updated `handleReAuthenticate()` to support both Zoho and Salesforce
|
||||||
|
- Added Salesforce Auth modal integration
|
||||||
|
- Enhanced authentication logic to distinguish between services
|
||||||
|
- Added re-authentication button for Salesforce
|
||||||
|
|
||||||
|
### 3. **Deep Linking Configuration**
|
||||||
|
- **iOS**: Updated `Info.plist` with `CFBundleURLTypes` for custom URL scheme
|
||||||
|
- **Android**: Already configured in `AndroidManifest.xml` (existing deep linking support)
|
||||||
|
- URL Scheme: `centralizedreportingsystem://oauth/salesforce/callback`
|
||||||
|
|
||||||
|
### 4. **Comprehensive Setup Guide** (`src/modules/integrations/screens/SALESFORCE_SETUP.md`)
|
||||||
|
- Step-by-step Salesforce Connected App creation
|
||||||
|
- OAuth configuration instructions
|
||||||
|
- Mobile app configuration guide
|
||||||
|
- Backend implementation guidance
|
||||||
|
- Troubleshooting section
|
||||||
|
- Security best practices
|
||||||
|
- Testing procedures
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔧 Configuration Required
|
||||||
|
|
||||||
|
### Step 1: Create Salesforce Connected App
|
||||||
|
|
||||||
|
1. **Access Salesforce Setup**
|
||||||
|
- Log in to Salesforce (Production or Sandbox)
|
||||||
|
- Click gear icon (⚙️) → Setup
|
||||||
|
|
||||||
|
2. **Create Connected App**
|
||||||
|
- Navigate to App Manager
|
||||||
|
- Click "New Connected App"
|
||||||
|
- Fill in basic information:
|
||||||
|
- **Name**: `Centralized Reporting System`
|
||||||
|
- **Contact Email**: Your email
|
||||||
|
|
||||||
|
3. **Enable OAuth Settings**
|
||||||
|
- Check "Enable OAuth Settings"
|
||||||
|
- **Callback URL**: `centralizedreportingsystem://oauth/salesforce/callback`
|
||||||
|
- **Select OAuth Scopes**:
|
||||||
|
- Access the identity URL service (id, profile, email, address, phone)
|
||||||
|
- Access unique user identifiers (openid)
|
||||||
|
- Perform requests at any time (refresh_token, offline_access)
|
||||||
|
- Manage user data via APIs (api)
|
||||||
|
|
||||||
|
4. **Save and Retrieve Credentials**
|
||||||
|
- Wait 2-10 minutes for processing
|
||||||
|
- Navigate back to App Manager → View your app
|
||||||
|
- Copy the **Consumer Key** (Client ID)
|
||||||
|
- Reveal and copy the **Consumer Secret** (Client Secret)
|
||||||
|
|
||||||
|
### Step 2: Update Application Configuration
|
||||||
|
|
||||||
|
Open `src/modules/integrations/screens/SalesforceAuth.tsx` and update:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const SALESFORCE_CONFIG = {
|
||||||
|
// ⚠️ REPLACE with your Consumer Key from Salesforce
|
||||||
|
CLIENT_ID: 'YOUR_CONSUMER_KEY_HERE',
|
||||||
|
|
||||||
|
// This must match exactly what you configured in Salesforce
|
||||||
|
REDIRECT_URI: 'centralizedreportingsystem://oauth/salesforce/callback',
|
||||||
|
|
||||||
|
// Production: https://login.salesforce.com
|
||||||
|
// Sandbox: https://test.salesforce.com
|
||||||
|
AUTH_BASE_URL: 'https://login.salesforce.com',
|
||||||
|
|
||||||
|
RESPONSE_TYPE: 'code',
|
||||||
|
DISPLAY: 'touch',
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 3: Backend Token Exchange
|
||||||
|
|
||||||
|
Your backend needs to implement the token exchange endpoint:
|
||||||
|
|
||||||
|
**Endpoint**: `POST /api/v1/integrations/manage-token`
|
||||||
|
|
||||||
|
**Request Body**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"authorization_code": "aPrxXXXXXXXXX",
|
||||||
|
"id": "user-uuid",
|
||||||
|
"service_name": "salesforce",
|
||||||
|
"access_token": "user-session-token"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Backend Should**:
|
||||||
|
1. Receive the authorization code
|
||||||
|
2. Exchange it with Salesforce for an access token:
|
||||||
|
```http
|
||||||
|
POST https://login.salesforce.com/services/oauth2/token
|
||||||
|
Content-Type: application/x-www-form-urlencoded
|
||||||
|
|
||||||
|
grant_type=authorization_code
|
||||||
|
&code={AUTH_CODE}
|
||||||
|
&client_id={CLIENT_ID}
|
||||||
|
&client_secret={CLIENT_SECRET}
|
||||||
|
&redirect_uri={REDIRECT_URI}
|
||||||
|
```
|
||||||
|
3. Store tokens securely (encrypted)
|
||||||
|
4. Return success/failure response
|
||||||
|
|
||||||
|
**Salesforce Response**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"access_token": "00D...ABC",
|
||||||
|
"refresh_token": "5Aep...XYZ",
|
||||||
|
"instance_url": "https://yourinstance.salesforce.com",
|
||||||
|
"id": "https://login.salesforce.com/id/00D.../005...",
|
||||||
|
"token_type": "Bearer",
|
||||||
|
"issued_at": "1699999999999"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 4: Optional - Data Sync Endpoint
|
||||||
|
|
||||||
|
If you want to sync Salesforce data, implement:
|
||||||
|
|
||||||
|
**Endpoint**: `POST /api/v1/integrations/salesforce/sync/schedule`
|
||||||
|
|
||||||
|
This is called automatically after successful authentication to initiate data synchronization.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚀 How to Use
|
||||||
|
|
||||||
|
### For End Users:
|
||||||
|
|
||||||
|
1. **Navigate to Integration**
|
||||||
|
- Open app → Integrations tab
|
||||||
|
- Tap "CRM & Sales Integration"
|
||||||
|
|
||||||
|
2. **Select Salesforce**
|
||||||
|
- Tap on "Salesforce" service
|
||||||
|
- If not authenticated, Salesforce login screen will appear
|
||||||
|
|
||||||
|
3. **Authenticate**
|
||||||
|
- Enter Salesforce credentials
|
||||||
|
- Complete 2FA if enabled
|
||||||
|
- Approve OAuth permissions
|
||||||
|
|
||||||
|
4. **Success**
|
||||||
|
- Authorization code is captured
|
||||||
|
- Backend exchanges code for tokens
|
||||||
|
- Processing modal shows progress
|
||||||
|
- Modal closes automatically on success
|
||||||
|
|
||||||
|
5. **Re-authenticate (Optional)**
|
||||||
|
- Tap the "Re-auth" button next to Salesforce
|
||||||
|
- Useful for changing organizations or refreshing permissions
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🗂️ File Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
src/modules/integrations/screens/
|
||||||
|
├── SalesforceAuth.tsx # New: Salesforce OAuth component
|
||||||
|
├── ZohoAuth.tsx # Existing: Zoho OAuth component
|
||||||
|
├── IntegrationCategoryScreen.tsx # Updated: Added Salesforce support
|
||||||
|
├── SALESFORCE_SETUP.md # New: Setup documentation
|
||||||
|
└── ...
|
||||||
|
|
||||||
|
ios/CentralizedReportingSystem/
|
||||||
|
└── Info.plist # Updated: Added CFBundleURLTypes
|
||||||
|
|
||||||
|
android/app/src/main/
|
||||||
|
└── AndroidManifest.xml # Already configured (no changes needed)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔒 Security Considerations
|
||||||
|
|
||||||
|
1. **Never Commit Credentials**
|
||||||
|
- Store Client ID and Secret in environment variables
|
||||||
|
- Use `.env` files (don't commit to Git)
|
||||||
|
- Example:
|
||||||
|
```bash
|
||||||
|
# .env
|
||||||
|
SALESFORCE_CLIENT_ID=your_consumer_key
|
||||||
|
SALESFORCE_CLIENT_SECRET=your_consumer_secret
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Backend Token Storage**
|
||||||
|
- Encrypt tokens before storing
|
||||||
|
- Use secure database encryption
|
||||||
|
- Implement token rotation
|
||||||
|
|
||||||
|
3. **HTTPS Only**
|
||||||
|
- Always use `https://` for Salesforce APIs
|
||||||
|
- Never downgrade to HTTP in production
|
||||||
|
|
||||||
|
4. **Minimal Scopes**
|
||||||
|
- Request only necessary OAuth scopes
|
||||||
|
- Avoid `full` access unless required
|
||||||
|
|
||||||
|
5. **State Parameter** (Future Enhancement)
|
||||||
|
- Consider adding CSRF protection with state parameter
|
||||||
|
- Validate state on callback
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🧪 Testing Checklist
|
||||||
|
|
||||||
|
- [ ] Create Salesforce Connected App
|
||||||
|
- [ ] Update CLIENT_ID in SalesforceAuth.tsx
|
||||||
|
- [ ] Backend token exchange endpoint is ready
|
||||||
|
- [ ] Build and run the app (both iOS and Android)
|
||||||
|
- [ ] Navigate to CRM & Sales Integration
|
||||||
|
- [ ] Tap Salesforce service
|
||||||
|
- [ ] Complete OAuth flow
|
||||||
|
- [ ] Verify authorization code is captured
|
||||||
|
- [ ] Check backend logs for successful token exchange
|
||||||
|
- [ ] Test re-authentication flow
|
||||||
|
- [ ] Test with Production Salesforce org
|
||||||
|
- [ ] Test with Sandbox Salesforce org (if applicable)
|
||||||
|
- [ ] Verify error handling (wrong credentials, network failure)
|
||||||
|
- [ ] Test deep linking callback on both platforms
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🐛 Common Issues & Solutions
|
||||||
|
|
||||||
|
### Issue 1: "redirect_uri_mismatch"
|
||||||
|
**Solution**: Ensure the redirect URI in code exactly matches Salesforce Connected App configuration.
|
||||||
|
|
||||||
|
### Issue 2: "invalid_client_id"
|
||||||
|
**Solution**: Wait 2-10 minutes after creating Connected App, or verify the Client ID is correct.
|
||||||
|
|
||||||
|
### Issue 3: WebView Doesn't Load
|
||||||
|
**Solution**: Check internet connection, verify AUTH_BASE_URL is correct.
|
||||||
|
|
||||||
|
### Issue 4: App Doesn't Capture Code
|
||||||
|
**Solution**: Verify deep linking is configured correctly in Info.plist and AndroidManifest.xml.
|
||||||
|
|
||||||
|
### Issue 5: Backend Token Exchange Fails
|
||||||
|
**Solution**: Verify backend endpoint, check Consumer Secret, ensure authorization code hasn't expired (15 min limit).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📊 Architecture Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
User Taps Salesforce
|
||||||
|
↓
|
||||||
|
Check for Existing Token (checkSalesforceToken)
|
||||||
|
↓
|
||||||
|
[No Token?]
|
||||||
|
↓
|
||||||
|
Open SalesforceAuth Modal
|
||||||
|
↓
|
||||||
|
Display Salesforce Login (WebView)
|
||||||
|
↓
|
||||||
|
User Authenticates
|
||||||
|
↓
|
||||||
|
Salesforce Redirects to: centralizedreportingsystem://oauth/salesforce/callback?code=...
|
||||||
|
↓
|
||||||
|
App Captures Authorization Code
|
||||||
|
↓
|
||||||
|
Send Code to Backend (manageToken API)
|
||||||
|
↓
|
||||||
|
Backend Exchanges Code for Tokens (Salesforce API)
|
||||||
|
↓
|
||||||
|
Backend Stores Tokens (Encrypted)
|
||||||
|
↓
|
||||||
|
[Optional] Schedule Data Sync
|
||||||
|
↓
|
||||||
|
Success! Close Modal
|
||||||
|
↓
|
||||||
|
User is Authenticated
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📋 Next Steps
|
||||||
|
|
||||||
|
### Immediate (Required):
|
||||||
|
1. ✅ Create Salesforce Connected App
|
||||||
|
2. ✅ Update `CLIENT_ID` in `SalesforceAuth.tsx`
|
||||||
|
3. ✅ Implement backend token exchange endpoint
|
||||||
|
4. ✅ Test the integration end-to-end
|
||||||
|
|
||||||
|
### Future Enhancements:
|
||||||
|
- [ ] Add token refresh logic
|
||||||
|
- [ ] Implement data synchronization from Salesforce
|
||||||
|
- [ ] Add Salesforce dashboard screens
|
||||||
|
- [ ] Support multiple Salesforce orgs per user
|
||||||
|
- [ ] Add offline token management
|
||||||
|
- [ ] Implement webhook support for real-time updates
|
||||||
|
- [ ] Add analytics for Salesforce data
|
||||||
|
- [ ] Create Salesforce-specific widgets
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📚 Resources
|
||||||
|
|
||||||
|
### Documentation Files:
|
||||||
|
- **Setup Guide**: `src/modules/integrations/screens/SALESFORCE_SETUP.md`
|
||||||
|
- **Component**: `src/modules/integrations/screens/SalesforceAuth.tsx`
|
||||||
|
- **Integration Screen**: `src/modules/integrations/screens/IntegrationCategoryScreen.tsx`
|
||||||
|
|
||||||
|
### External Resources:
|
||||||
|
- [Salesforce Connected Apps](https://help.salesforce.com/s/articleView?id=sf.connected_app_overview.htm)
|
||||||
|
- [Salesforce OAuth 2.0](https://help.salesforce.com/s/articleView?id=sf.remoteaccess_oauth_flow.htm)
|
||||||
|
- [React Native WebView](https://github.com/react-native-webview/react-native-webview)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 Key Features
|
||||||
|
|
||||||
|
✅ Full OAuth 2.0 authentication flow
|
||||||
|
✅ Mobile-optimized login experience
|
||||||
|
✅ Automatic authorization code capture
|
||||||
|
✅ Backend token exchange integration
|
||||||
|
✅ Re-authentication support
|
||||||
|
✅ Comprehensive error handling
|
||||||
|
✅ Loading and processing states
|
||||||
|
✅ Deep linking configuration
|
||||||
|
✅ Production and Sandbox support
|
||||||
|
✅ Consistent with existing Zoho integration
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🤝 Support
|
||||||
|
|
||||||
|
If you encounter issues:
|
||||||
|
1. Check the `SALESFORCE_SETUP.md` guide
|
||||||
|
2. Review console logs for error messages
|
||||||
|
3. Verify Salesforce Connected App configuration
|
||||||
|
4. Check backend logs for token exchange errors
|
||||||
|
5. Test with both Production and Sandbox environments
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Implementation Date**: October 2025
|
||||||
|
**Status**: ✅ Complete - Pending Configuration
|
||||||
|
**Version**: 1.0.0
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📝 Notes for Developers
|
||||||
|
|
||||||
|
- The Salesforce authentication follows the same pattern as Zoho authentication for consistency
|
||||||
|
- Authorization codes expire in 15 minutes - ensure timely backend exchange
|
||||||
|
- The `manageToken` API endpoint is reused for both Zoho and Salesforce (differentiated by `service_name` parameter)
|
||||||
|
- Deep linking is already configured for the app; Salesforce uses the same scheme
|
||||||
|
- The component includes comprehensive logging for debugging
|
||||||
|
- Error handling includes automatic retry functionality
|
||||||
|
- The processing modal provides user feedback during async operations
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Ready to Deploy!** 🚀
|
||||||
|
|
||||||
|
Once you've completed the configuration steps above, the Salesforce integration will be fully functional. Users will be able to authenticate with their Salesforce accounts directly from the CRM & Sales Integration category.
|
||||||
|
|
||||||
@ -56,5 +56,16 @@
|
|||||||
<string>Roboto-SemiBold.ttf</string>
|
<string>Roboto-SemiBold.ttf</string>
|
||||||
<string>Roboto-Thin.ttf</string>
|
<string>Roboto-Thin.ttf</string>
|
||||||
</array>
|
</array>
|
||||||
|
<key>CFBundleURLTypes</key>
|
||||||
|
<array>
|
||||||
|
<dict>
|
||||||
|
<key>CFBundleURLSchemes</key>
|
||||||
|
<array>
|
||||||
|
<string>centralizedreportingsystem</string>
|
||||||
|
</array>
|
||||||
|
<key>CFBundleURLName</key>
|
||||||
|
<string>com.centralizedreportingsystem</string>
|
||||||
|
</dict>
|
||||||
|
</array>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|||||||
@ -1,16 +1,30 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { createStackNavigator } from '@react-navigation/stack';
|
import { createStackNavigator } from '@react-navigation/stack';
|
||||||
|
import { useSelector } from 'react-redux';
|
||||||
|
import type { RootState } from '@/store/store';
|
||||||
import CrmDashboardScreen from '@/modules/crm/zoho/screens/CrmDashboardScreen';
|
import CrmDashboardScreen from '@/modules/crm/zoho/screens/CrmDashboardScreen';
|
||||||
import ZohoCrmDataScreen from '@/modules/crm/zoho/screens/ZohoCrmDataScreen';
|
import ZohoCrmDataScreen from '@/modules/crm/zoho/screens/ZohoCrmDataScreen';
|
||||||
|
import SalesforceCrmDashboardScreen from '@/modules/crm/salesforce/screens/SalesforceCrmDashboardScreen';
|
||||||
|
import SalesforceDataScreen from '@/modules/crm/salesforce/screens/SalesforceDataScreen';
|
||||||
|
|
||||||
const Stack = createStackNavigator();
|
const Stack = createStackNavigator();
|
||||||
|
|
||||||
const CrmNavigator = () => (
|
const CrmNavigator = () => {
|
||||||
<Stack.Navigator>
|
// Get the selected service from Redux to determine initial screen
|
||||||
|
const selectedService = useSelector((state: RootState) => state.integrations.selectedService);
|
||||||
|
|
||||||
|
// Determine initial route based on selected CRM service
|
||||||
|
const initialRouteName = selectedService === 'salesforce' ? 'SalesforceDashboard' : 'CrmDashboard';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack.Navigator initialRouteName={initialRouteName}>
|
||||||
<Stack.Screen name="CrmDashboard" component={CrmDashboardScreen} options={{headerShown:false}} />
|
<Stack.Screen name="CrmDashboard" component={CrmDashboardScreen} options={{headerShown:false}} />
|
||||||
<Stack.Screen name="ZohoCrmData" component={ZohoCrmDataScreen} options={{headerShown:false}} />
|
<Stack.Screen name="ZohoCrmData" component={ZohoCrmDataScreen} options={{headerShown:false}} />
|
||||||
|
<Stack.Screen name="SalesforceDashboard" component={SalesforceCrmDashboardScreen} options={{headerShown:false}} />
|
||||||
|
<Stack.Screen name="SalesforceData" component={SalesforceDataScreen} options={{headerShown:false}} />
|
||||||
</Stack.Navigator>
|
</Stack.Navigator>
|
||||||
);
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export default CrmNavigator;
|
export default CrmNavigator;
|
||||||
|
|
||||||
|
|||||||
265
src/modules/crm/salesforce/README.md
Normal file
265
src/modules/crm/salesforce/README.md
Normal file
@ -0,0 +1,265 @@
|
|||||||
|
# Salesforce CRM Module
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
This module provides a complete Salesforce CRM integration for the Centralized Reporting System, including dashboard visualizations, data management, and comprehensive analytics.
|
||||||
|
|
||||||
|
## Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
salesforce/
|
||||||
|
├── components/
|
||||||
|
│ └── SalesforceDataCards.tsx # Card components for each data type
|
||||||
|
├── screens/
|
||||||
|
│ ├── SalesforceCrmDashboardScreen.tsx # Dashboard with KPIs and charts
|
||||||
|
│ └── SalesforceDataScreen.tsx # Tabbed data view with all CRM data
|
||||||
|
├── services/
|
||||||
|
│ └── salesforceCrmAPI.ts # API service for Salesforce data
|
||||||
|
├── store/
|
||||||
|
│ ├── salesforceCrmSlice.ts # Redux slice with state management
|
||||||
|
│ └── selectors.ts # Redux selectors
|
||||||
|
├── types/
|
||||||
|
│ └── SalesforceCrmTypes.ts # TypeScript type definitions
|
||||||
|
└── index.ts # Module exports
|
||||||
|
```
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
The module integrates with the following Salesforce CRM endpoints:
|
||||||
|
|
||||||
|
- **Events**: `/api/v1/n8n/salesforce/crm/events`
|
||||||
|
- **Leads**: `/api/v1/n8n/salesforce/crm/leads`
|
||||||
|
- **Tasks**: `/api/v1/n8n/salesforce/crm/tasks`
|
||||||
|
- **Accounts**: `/api/v1/n8n/salesforce/crm/accounts`
|
||||||
|
- **Opportunities**: `/api/v1/n8n/salesforce/crm/opportunities`
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
### Dashboard Screen (`SalesforceCrmDashboardScreen`)
|
||||||
|
- **Hero Stats**: Large cards showing Leads and Opportunities totals
|
||||||
|
- **Pipeline Overview**: Total pipeline value, average deal size, and open deals
|
||||||
|
- **Module Counts**: Compact grid showing Events, Tasks, and Accounts counts
|
||||||
|
- **Lead Status Distribution**: Pie chart showing New, Working, and Qualified leads
|
||||||
|
- **Task Status**: Stacked bar chart showing Open, Completed, and High Priority tasks
|
||||||
|
- **Key Metrics**: Upcoming Events and Won Deals summary
|
||||||
|
- **Pull-to-refresh** functionality
|
||||||
|
- **Real-time data updates**
|
||||||
|
|
||||||
|
### Data Screen (`SalesforceDataScreen`)
|
||||||
|
- **Tabbed Interface**: Switch between Events, Leads, Tasks, Accounts, and Opportunities
|
||||||
|
- **Data Cards**: Rich card display for each data type with relevant information
|
||||||
|
- **Infinite Scrolling**: Load more data as you scroll
|
||||||
|
- **Count Badges**: Show total count for each tab
|
||||||
|
- **Pull-to-refresh** functionality
|
||||||
|
- **Error handling and loading states**
|
||||||
|
|
||||||
|
### Data Types
|
||||||
|
|
||||||
|
#### Event
|
||||||
|
- Subject, Location, Description
|
||||||
|
- Start/End DateTime
|
||||||
|
- All Day Event indicator
|
||||||
|
- Created date
|
||||||
|
|
||||||
|
#### Lead
|
||||||
|
- Name (First + Last)
|
||||||
|
- Company, Email, Phone
|
||||||
|
- Status (New, Working, Nurturing, Qualified, Unqualified)
|
||||||
|
- Title, Lead Source
|
||||||
|
- Created date
|
||||||
|
|
||||||
|
#### Task
|
||||||
|
- Subject, Description
|
||||||
|
- Status (Open, In Progress, Completed, Waiting, Deferred)
|
||||||
|
- Priority (High, Normal, Low)
|
||||||
|
- Activity Date
|
||||||
|
- Created/Modified dates
|
||||||
|
|
||||||
|
#### Account
|
||||||
|
- Name, Industry, Type
|
||||||
|
- Phone, Website
|
||||||
|
- Billing Address (City, State, Country)
|
||||||
|
- Annual Revenue
|
||||||
|
- Created date
|
||||||
|
|
||||||
|
#### Opportunity
|
||||||
|
- Name, Stage, Type
|
||||||
|
- Amount, Probability
|
||||||
|
- Close Date, Forecast Category
|
||||||
|
- Created/Modified dates
|
||||||
|
|
||||||
|
## Redux State Management
|
||||||
|
|
||||||
|
### State Structure
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
events: SalesforceEvent[];
|
||||||
|
leads: SalesforceLead[];
|
||||||
|
tasks: SalesforceTask[];
|
||||||
|
accounts: SalesforceAccount[];
|
||||||
|
opportunities: SalesforceOpportunity[];
|
||||||
|
loading: { /* loading states for each type */ };
|
||||||
|
errors: { /* error states for each type */ };
|
||||||
|
pagination: { /* pagination info for each type */ };
|
||||||
|
counts: { /* total counts from API */ };
|
||||||
|
lastUpdated: { /* last updated timestamps */ };
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Actions
|
||||||
|
- `fetchAllSalesforceCrmData()` - Fetch all data types in parallel
|
||||||
|
- `fetchSalesforceEvents()` - Fetch events with pagination
|
||||||
|
- `fetchSalesforceLeads()` - Fetch leads with pagination
|
||||||
|
- `fetchSalesforceTasks()` - Fetch tasks with pagination
|
||||||
|
- `fetchSalesforceAccounts()` - Fetch accounts with pagination
|
||||||
|
- `fetchSalesforceOpportunities()` - Fetch opportunities with pagination
|
||||||
|
- `fetchSalesforceCrmCounts()` - Fetch total counts for all types
|
||||||
|
- `resetEventsPagination()` - Reset events pagination
|
||||||
|
- `resetLeadsPagination()` - Reset leads pagination
|
||||||
|
- `resetTasksPagination()` - Reset tasks pagination
|
||||||
|
- `resetAccountsPagination()` - Reset accounts pagination
|
||||||
|
- `resetOpportunitiesPagination()` - Reset opportunities pagination
|
||||||
|
- `clearData()` - Clear all data
|
||||||
|
- `clearErrors()` - Clear all errors
|
||||||
|
|
||||||
|
### Selectors
|
||||||
|
- `selectSalesforceEvents` - Get events array
|
||||||
|
- `selectSalesforceLeads` - Get leads array
|
||||||
|
- `selectSalesforceTasks` - Get tasks array
|
||||||
|
- `selectSalesforceAccounts` - Get accounts array
|
||||||
|
- `selectSalesforceOpportunities` - Get opportunities array
|
||||||
|
- `selectSalesforceCrmLoading` - Get loading states
|
||||||
|
- `selectSalesforceCrmErrors` - Get error states
|
||||||
|
- `selectSalesforceCrmCounts` - Get total counts
|
||||||
|
- `selectDashboardData` - Get computed dashboard data with summary
|
||||||
|
- `selectIsAnyLoading` - Check if any data is loading
|
||||||
|
- `selectHasAnyError` - Check if any errors exist
|
||||||
|
|
||||||
|
## Navigation
|
||||||
|
|
||||||
|
The module is integrated into the CRM navigator:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
<Stack.Screen
|
||||||
|
name="SalesforceDashboard"
|
||||||
|
component={SalesforceCrmDashboardScreen}
|
||||||
|
options={{headerShown:false}}
|
||||||
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name="SalesforceData"
|
||||||
|
component={SalesforceDataScreen}
|
||||||
|
options={{headerShown:false}}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
### Navigate to Salesforce Dashboard
|
||||||
|
```typescript
|
||||||
|
navigation.navigate('SalesforceDashboard');
|
||||||
|
```
|
||||||
|
|
||||||
|
### Navigate to Salesforce Data Screen
|
||||||
|
```typescript
|
||||||
|
navigation.navigate('SalesforceData');
|
||||||
|
```
|
||||||
|
|
||||||
|
### Fetch Salesforce Data
|
||||||
|
```typescript
|
||||||
|
import { useDispatch } from 'react-redux';
|
||||||
|
import { fetchAllSalesforceCrmData } from '@/modules/crm/salesforce';
|
||||||
|
|
||||||
|
const dispatch = useDispatch();
|
||||||
|
|
||||||
|
// Fetch all data
|
||||||
|
await dispatch(fetchAllSalesforceCrmData()).unwrap();
|
||||||
|
|
||||||
|
// Fetch specific data type with pagination
|
||||||
|
await dispatch(fetchSalesforceLeads({ page: 1, limit: 20 })).unwrap();
|
||||||
|
```
|
||||||
|
|
||||||
|
### Access Salesforce Data
|
||||||
|
```typescript
|
||||||
|
import { useSelector } from 'react-redux';
|
||||||
|
import { selectSalesforceLeads, selectDashboardData } from '@/modules/crm/salesforce';
|
||||||
|
|
||||||
|
// Get leads
|
||||||
|
const leads = useSelector(selectSalesforceLeads);
|
||||||
|
|
||||||
|
// Get dashboard data with computed summaries
|
||||||
|
const dashboardData = useSelector(selectDashboardData);
|
||||||
|
```
|
||||||
|
|
||||||
|
## API Response Format
|
||||||
|
|
||||||
|
All endpoints return responses in the following format:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "success",
|
||||||
|
"message": "Salesforce crm {resource} data fetched successfully",
|
||||||
|
"data": {
|
||||||
|
"success": true,
|
||||||
|
"data": [ /* array of records */ ],
|
||||||
|
"count": 56,
|
||||||
|
"metadata": {
|
||||||
|
"totalSize": 56,
|
||||||
|
"done": true,
|
||||||
|
"nextRecordsUrl": null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"timestamp": "2025-10-09T06:40:31.428Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Styling
|
||||||
|
|
||||||
|
The module follows the application's design system:
|
||||||
|
- Uses theme colors from `useTheme()` hook
|
||||||
|
- Consistent spacing and typography
|
||||||
|
- Responsive layouts
|
||||||
|
- Material Community Icons
|
||||||
|
- Smooth animations and transitions
|
||||||
|
|
||||||
|
## Data Persistence
|
||||||
|
|
||||||
|
The Salesforce CRM state is persisted using Redux Persist:
|
||||||
|
- State is saved to AsyncStorage
|
||||||
|
- Survives app restarts
|
||||||
|
- Automatically rehydrated on app launch
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
- All API calls wrapped in try-catch blocks
|
||||||
|
- Error states stored in Redux
|
||||||
|
- User-friendly error messages via Toast notifications
|
||||||
|
- Retry functionality on error screens
|
||||||
|
- Loading states for all async operations
|
||||||
|
|
||||||
|
## Performance Optimizations
|
||||||
|
|
||||||
|
- **Parallel Data Fetching**: All data types fetched simultaneously
|
||||||
|
- **Infinite Scrolling**: Load data in chunks to reduce initial load time
|
||||||
|
- **Memoized Selectors**: Prevent unnecessary re-renders
|
||||||
|
- **Redux Persist**: Cache data locally to reduce API calls
|
||||||
|
- **Pull-to-refresh**: Manual refresh when needed
|
||||||
|
- **Optimized Re-renders**: Use React.memo where appropriate
|
||||||
|
|
||||||
|
## Future Enhancements
|
||||||
|
|
||||||
|
- Advanced filtering options
|
||||||
|
- Search functionality
|
||||||
|
- Sort options for each data type
|
||||||
|
- Export data to CSV/Excel
|
||||||
|
- Detailed view screens for each record
|
||||||
|
- Create/Edit/Delete operations
|
||||||
|
- Real-time sync with Salesforce
|
||||||
|
- Offline mode support
|
||||||
|
- Analytics and reporting
|
||||||
|
- Custom dashboard widgets
|
||||||
|
|
||||||
|
## Related Modules
|
||||||
|
|
||||||
|
- **Zoho CRM** (`src/modules/crm/zoho/`) - Similar structure for Zoho integration
|
||||||
|
- **CRM Navigator** (`src/modules/crm/navigation/CrmNavigator.tsx`) - Navigation configuration
|
||||||
|
- **Root Store** (`src/store/store.ts`) - Redux store configuration
|
||||||
|
|
||||||
453
src/modules/crm/salesforce/components/SalesforceDataCards.tsx
Normal file
453
src/modules/crm/salesforce/components/SalesforceDataCards.tsx
Normal file
@ -0,0 +1,453 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { View, Text, StyleSheet, TouchableOpacity } from 'react-native';
|
||||||
|
import Icon from 'react-native-vector-icons/MaterialCommunityIcons';
|
||||||
|
import { useTheme } from '@/shared/styles/useTheme';
|
||||||
|
import type { SalesforceEvent, SalesforceLead, SalesforceTask, SalesforceAccount, SalesforceOpportunity } from '../types/SalesforceCrmTypes';
|
||||||
|
|
||||||
|
interface BaseCardProps {
|
||||||
|
onPress: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EventCardProps extends BaseCardProps {
|
||||||
|
event: SalesforceEvent;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LeadCardProps extends BaseCardProps {
|
||||||
|
lead: SalesforceLead;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TaskCardProps extends BaseCardProps {
|
||||||
|
task: SalesforceTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AccountCardProps extends BaseCardProps {
|
||||||
|
account: SalesforceAccount;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OpportunityCardProps extends BaseCardProps {
|
||||||
|
opportunity: SalesforceOpportunity;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to get status-based colors
|
||||||
|
const getStatusColor = (status: string, colors: any) => {
|
||||||
|
switch (status.toLowerCase()) {
|
||||||
|
case 'new':
|
||||||
|
case 'open':
|
||||||
|
case 'prospecting':
|
||||||
|
return '#3AA0FF';
|
||||||
|
case 'working':
|
||||||
|
case 'in progress':
|
||||||
|
case 'qualification':
|
||||||
|
case 'needs analysis':
|
||||||
|
return '#F59E0B';
|
||||||
|
case 'qualified':
|
||||||
|
case 'completed':
|
||||||
|
case 'closed won':
|
||||||
|
return '#22C55E';
|
||||||
|
case 'unqualified':
|
||||||
|
case 'closed lost':
|
||||||
|
return '#EF4444';
|
||||||
|
default:
|
||||||
|
return colors.textLight;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Helper function to get priority-based colors
|
||||||
|
const getPriorityColor = (priority: string) => {
|
||||||
|
switch (priority.toLowerCase()) {
|
||||||
|
case 'high':
|
||||||
|
return '#EF4444';
|
||||||
|
case 'normal':
|
||||||
|
return '#F59E0B';
|
||||||
|
case 'low':
|
||||||
|
return '#10B981';
|
||||||
|
default:
|
||||||
|
return '#6B7280';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const EventCard: React.FC<EventCardProps> = ({ event, onPress }) => {
|
||||||
|
const { colors, fonts, shadows } = useTheme();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.card, { backgroundColor: colors.surface, borderColor: colors.border, ...shadows.medium }]}
|
||||||
|
onPress={onPress}
|
||||||
|
activeOpacity={0.8}
|
||||||
|
>
|
||||||
|
<View style={styles.cardHeader}>
|
||||||
|
<View style={styles.cardTitleRow}>
|
||||||
|
<Text style={[styles.cardTitle, { color: colors.text, fontFamily: fonts.bold }]} numberOfLines={2}>
|
||||||
|
{event.Subject}
|
||||||
|
</Text>
|
||||||
|
{event.IsAllDayEvent && (
|
||||||
|
<View style={[styles.statusBadge, { backgroundColor: colors.primary }]}>
|
||||||
|
<Text style={[styles.statusText, { color: colors.surface, fontFamily: fonts.medium }]}>
|
||||||
|
All Day
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
{event.Location && (
|
||||||
|
<Text style={[styles.cardSubtitle, { color: colors.textLight, fontFamily: fonts.regular }]} numberOfLines={1}>
|
||||||
|
{event.Location}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.cardContent}>
|
||||||
|
{event.StartDateTime && (
|
||||||
|
<View style={styles.infoRow}>
|
||||||
|
<Icon name="calendar-start" size={16} color={colors.textLight} />
|
||||||
|
<Text style={[styles.infoText, { color: colors.text, fontFamily: fonts.regular }]}>
|
||||||
|
Start: {new Date(event.StartDateTime).toLocaleString()}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
{event.EndDateTime && (
|
||||||
|
<View style={styles.infoRow}>
|
||||||
|
<Icon name="calendar-end" size={16} color={colors.textLight} />
|
||||||
|
<Text style={[styles.infoText, { color: colors.text, fontFamily: fonts.regular }]}>
|
||||||
|
End: {new Date(event.EndDateTime).toLocaleString()}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
{event.Description && (
|
||||||
|
<View style={styles.infoRow}>
|
||||||
|
<Icon name="text" size={16} color={colors.textLight} />
|
||||||
|
<Text style={[styles.infoText, { color: colors.text, fontFamily: fonts.regular }]} numberOfLines={2}>
|
||||||
|
{event.Description}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.cardFooter}>
|
||||||
|
<Text style={[styles.dateText, { color: colors.textLight, fontFamily: fonts.regular }]}>
|
||||||
|
Created: {new Date(event.CreatedDate).toLocaleDateString()}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const LeadCard: React.FC<LeadCardProps> = ({ lead, onPress }) => {
|
||||||
|
const { colors, fonts, shadows } = useTheme();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.card, { backgroundColor: colors.surface, borderColor: colors.border, ...shadows.medium }]}
|
||||||
|
onPress={onPress}
|
||||||
|
activeOpacity={0.8}
|
||||||
|
>
|
||||||
|
<View style={styles.cardHeader}>
|
||||||
|
<View style={styles.cardTitleRow}>
|
||||||
|
<Text style={[styles.cardTitle, { color: colors.text, fontFamily: fonts.bold }]} numberOfLines={1}>
|
||||||
|
{lead.FirstName} {lead.LastName}
|
||||||
|
</Text>
|
||||||
|
<View style={[styles.statusBadge, { backgroundColor: getStatusColor(lead.Status, colors) }]}>
|
||||||
|
<Text style={[styles.statusText, { color: colors.surface, fontFamily: fonts.medium }]}>
|
||||||
|
{lead.Status}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<Text style={[styles.cardSubtitle, { color: colors.textLight, fontFamily: fonts.regular }]} numberOfLines={1}>
|
||||||
|
{lead.Company}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.cardContent}>
|
||||||
|
<View style={styles.infoRow}>
|
||||||
|
<Icon name="email-outline" size={16} color={colors.textLight} />
|
||||||
|
<Text style={[styles.infoText, { color: colors.text, fontFamily: fonts.regular }]} numberOfLines={1}>
|
||||||
|
{lead.Email}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
{lead.Phone && (
|
||||||
|
<View style={styles.infoRow}>
|
||||||
|
<Icon name="phone-outline" size={16} color={colors.textLight} />
|
||||||
|
<Text style={[styles.infoText, { color: colors.text, fontFamily: fonts.regular }]}>
|
||||||
|
{lead.Phone}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
{lead.Title && (
|
||||||
|
<View style={styles.infoRow}>
|
||||||
|
<Icon name="briefcase-outline" size={16} color={colors.textLight} />
|
||||||
|
<Text style={[styles.infoText, { color: colors.text, fontFamily: fonts.regular }]}>
|
||||||
|
{lead.Title}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
{lead.LeadSource && (
|
||||||
|
<View style={styles.infoRow}>
|
||||||
|
<Icon name="source-branch" size={16} color={colors.textLight} />
|
||||||
|
<Text style={[styles.infoText, { color: colors.text, fontFamily: fonts.regular }]}>
|
||||||
|
Source: {lead.LeadSource}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.cardFooter}>
|
||||||
|
<Text style={[styles.dateText, { color: colors.textLight, fontFamily: fonts.regular }]}>
|
||||||
|
Created: {lead.CreatedDate ? new Date(lead.CreatedDate).toLocaleDateString() : 'N/A'}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const TaskCard: React.FC<TaskCardProps> = ({ task, onPress }) => {
|
||||||
|
const { colors, fonts, shadows } = useTheme();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.card, { backgroundColor: colors.surface, borderColor: colors.border, ...shadows.medium }]}
|
||||||
|
onPress={onPress}
|
||||||
|
activeOpacity={0.8}
|
||||||
|
>
|
||||||
|
<View style={styles.cardHeader}>
|
||||||
|
<View style={styles.cardTitleRow}>
|
||||||
|
<Text style={[styles.cardTitle, { color: colors.text, fontFamily: fonts.bold }]} numberOfLines={2}>
|
||||||
|
{task.Subject}
|
||||||
|
</Text>
|
||||||
|
<View style={[styles.statusBadge, { backgroundColor: getStatusColor(task.Status, colors) }]}>
|
||||||
|
<Text style={[styles.statusText, { color: colors.surface, fontFamily: fonts.medium }]}>
|
||||||
|
{task.Status}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.cardContent}>
|
||||||
|
{task.Description && (
|
||||||
|
<Text style={[styles.descriptionText, { color: colors.text, fontFamily: fonts.regular }]} numberOfLines={2}>
|
||||||
|
{task.Description}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
<View style={styles.infoRow}>
|
||||||
|
<Icon name="flag-outline" size={16} color={getPriorityColor(task.Priority)} />
|
||||||
|
<Text style={[styles.infoText, { color: colors.text, fontFamily: fonts.regular }]}>
|
||||||
|
Priority: {task.Priority}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
{task.ActivityDate && (
|
||||||
|
<View style={styles.infoRow}>
|
||||||
|
<Icon name="calendar-outline" size={16} color={colors.textLight} />
|
||||||
|
<Text style={[styles.infoText, { color: colors.text, fontFamily: fonts.regular }]}>
|
||||||
|
Due: {new Date(task.ActivityDate).toLocaleDateString()}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.cardFooter}>
|
||||||
|
<Text style={[styles.dateText, { color: colors.textLight, fontFamily: fonts.regular }]}>
|
||||||
|
Created: {new Date(task.CreatedDate).toLocaleDateString()}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AccountCard: React.FC<AccountCardProps> = ({ account, onPress }) => {
|
||||||
|
const { colors, fonts, shadows } = useTheme();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.card, { backgroundColor: colors.surface, borderColor: colors.border, ...shadows.medium }]}
|
||||||
|
onPress={onPress}
|
||||||
|
activeOpacity={0.8}
|
||||||
|
>
|
||||||
|
<View style={styles.cardHeader}>
|
||||||
|
<View style={styles.cardTitleRow}>
|
||||||
|
<Text style={[styles.cardTitle, { color: colors.text, fontFamily: fonts.bold }]} numberOfLines={1}>
|
||||||
|
{account.Name}
|
||||||
|
</Text>
|
||||||
|
{account.Type && (
|
||||||
|
<View style={[styles.statusBadge, { backgroundColor: colors.primary }]}>
|
||||||
|
<Text style={[styles.statusText, { color: colors.surface, fontFamily: fonts.medium }]}>
|
||||||
|
{account.Type}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
{account.Industry && (
|
||||||
|
<Text style={[styles.cardSubtitle, { color: colors.textLight, fontFamily: fonts.regular }]} numberOfLines={1}>
|
||||||
|
{account.Industry}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.cardContent}>
|
||||||
|
{account.Phone && (
|
||||||
|
<View style={styles.infoRow}>
|
||||||
|
<Icon name="phone-outline" size={16} color={colors.textLight} />
|
||||||
|
<Text style={[styles.infoText, { color: colors.text, fontFamily: fonts.regular }]}>
|
||||||
|
{account.Phone}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
{account.Website && (
|
||||||
|
<View style={styles.infoRow}>
|
||||||
|
<Icon name="web" size={16} color={colors.textLight} />
|
||||||
|
<Text style={[styles.infoText, { color: colors.text, fontFamily: fonts.regular }]} numberOfLines={1}>
|
||||||
|
{account.Website}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
{(account.BillingCity || account.BillingState) && (
|
||||||
|
<View style={styles.infoRow}>
|
||||||
|
<Icon name="map-marker-outline" size={16} color={colors.textLight} />
|
||||||
|
<Text style={[styles.infoText, { color: colors.text, fontFamily: fonts.regular }]}>
|
||||||
|
{[account.BillingCity, account.BillingState, account.BillingCountry].filter(Boolean).join(', ')}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
{account.AnnualRevenue && (
|
||||||
|
<View style={styles.infoRow}>
|
||||||
|
<Icon name="currency-usd" size={16} color={colors.primary} />
|
||||||
|
<Text style={[styles.infoText, { color: colors.primary, fontFamily: fonts.bold }]}>
|
||||||
|
Revenue: ${account.AnnualRevenue.toLocaleString()}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.cardFooter}>
|
||||||
|
<Text style={[styles.dateText, { color: colors.textLight, fontFamily: fonts.regular }]}>
|
||||||
|
Created: {new Date(account.CreatedDate).toLocaleDateString()}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const OpportunityCard: React.FC<OpportunityCardProps> = ({ opportunity, onPress }) => {
|
||||||
|
const { colors, fonts, shadows } = useTheme();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.card, { backgroundColor: colors.surface, borderColor: colors.border, ...shadows.medium }]}
|
||||||
|
onPress={onPress}
|
||||||
|
activeOpacity={0.8}
|
||||||
|
>
|
||||||
|
<View style={styles.cardHeader}>
|
||||||
|
<View style={styles.cardTitleRow}>
|
||||||
|
<Text style={[styles.cardTitle, { color: colors.text, fontFamily: fonts.bold }]} numberOfLines={1}>
|
||||||
|
{opportunity.Name}
|
||||||
|
</Text>
|
||||||
|
<View style={[styles.statusBadge, { backgroundColor: getStatusColor(opportunity.StageName, colors) }]}>
|
||||||
|
<Text style={[styles.statusText, { color: colors.surface, fontFamily: fonts.medium }]}>
|
||||||
|
{opportunity.StageName}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
{opportunity.Type && (
|
||||||
|
<Text style={[styles.cardSubtitle, { color: colors.textLight, fontFamily: fonts.regular }]} numberOfLines={1}>
|
||||||
|
{opportunity.Type}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.cardContent}>
|
||||||
|
<View style={styles.infoRow}>
|
||||||
|
<Icon name="currency-usd" size={16} color={colors.primary} />
|
||||||
|
<Text style={[styles.infoText, { color: colors.primary, fontFamily: fonts.bold }]}>
|
||||||
|
${opportunity.Amount?.toLocaleString()}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.infoRow}>
|
||||||
|
<Icon name="trending-up" size={16} color={colors.textLight} />
|
||||||
|
<Text style={[styles.infoText, { color: colors.text, fontFamily: fonts.regular }]}>
|
||||||
|
Probability: {opportunity.Probability}%
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.infoRow}>
|
||||||
|
<Icon name="calendar-outline" size={16} color={colors.textLight} />
|
||||||
|
<Text style={[styles.infoText, { color: colors.text, fontFamily: fonts.regular }]}>
|
||||||
|
Close date: {new Date(opportunity.CloseDate).toLocaleDateString()}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
{opportunity.ForecastCategory && (
|
||||||
|
<View style={styles.infoRow}>
|
||||||
|
<Icon name="chart-line" size={16} color={colors.textLight} />
|
||||||
|
<Text style={[styles.infoText, { color: colors.text, fontFamily: fonts.regular }]}>
|
||||||
|
Forecast: {opportunity.ForecastCategory}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.cardFooter}>
|
||||||
|
<Text style={[styles.dateText, { color: colors.textLight, fontFamily: fonts.regular }]}>
|
||||||
|
Created: {new Date(opportunity.CreatedDate).toLocaleDateString()}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
card: {
|
||||||
|
borderRadius: 12,
|
||||||
|
borderWidth: 1,
|
||||||
|
marginBottom: 16,
|
||||||
|
overflow: 'hidden',
|
||||||
|
},
|
||||||
|
cardHeader: {
|
||||||
|
padding: 16,
|
||||||
|
paddingBottom: 12,
|
||||||
|
},
|
||||||
|
cardTitleRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'flex-start',
|
||||||
|
marginBottom: 4,
|
||||||
|
},
|
||||||
|
cardTitle: {
|
||||||
|
fontSize: 16,
|
||||||
|
flex: 1,
|
||||||
|
marginRight: 8,
|
||||||
|
},
|
||||||
|
cardSubtitle: {
|
||||||
|
fontSize: 14,
|
||||||
|
},
|
||||||
|
statusBadge: {
|
||||||
|
paddingHorizontal: 8,
|
||||||
|
paddingVertical: 4,
|
||||||
|
borderRadius: 12,
|
||||||
|
},
|
||||||
|
statusText: {
|
||||||
|
fontSize: 12,
|
||||||
|
},
|
||||||
|
cardContent: {
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
paddingBottom: 12,
|
||||||
|
},
|
||||||
|
infoRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginBottom: 6,
|
||||||
|
},
|
||||||
|
infoText: {
|
||||||
|
marginLeft: 8,
|
||||||
|
fontSize: 14,
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
descriptionText: {
|
||||||
|
fontSize: 14,
|
||||||
|
marginBottom: 8,
|
||||||
|
lineHeight: 20,
|
||||||
|
},
|
||||||
|
cardFooter: {
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
paddingVertical: 12,
|
||||||
|
backgroundColor: '#F8F9FA',
|
||||||
|
},
|
||||||
|
dateText: {
|
||||||
|
fontSize: 12,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
8
src/modules/crm/salesforce/index.ts
Normal file
8
src/modules/crm/salesforce/index.ts
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
// Salesforce CRM Module Exports
|
||||||
|
export { default as SalesforceCrmDashboardScreen } from './screens/SalesforceCrmDashboardScreen';
|
||||||
|
export { default as SalesforceDataScreen } from './screens/SalesforceDataScreen';
|
||||||
|
export { default as salesforceCrmSlice } from './store/salesforceCrmSlice';
|
||||||
|
export { selectDashboardData, selectIsAnyLoading, selectHasAnyError, selectSalesforceCrmCounts } from './store/selectors';
|
||||||
|
export * from './types/SalesforceCrmTypes';
|
||||||
|
export { salesforceCrmAPI } from './services/salesforceCrmAPI';
|
||||||
|
|
||||||
@ -0,0 +1,468 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { View, Text, StyleSheet, TouchableOpacity, RefreshControl, ScrollView, ActivityIndicator } from 'react-native';
|
||||||
|
import Icon from 'react-native-vector-icons/MaterialCommunityIcons';
|
||||||
|
import { useSelector, useDispatch } from 'react-redux';
|
||||||
|
import { useTheme } from '@/shared/styles/useTheme';
|
||||||
|
import { useNavigation } from '@react-navigation/native';
|
||||||
|
import { fetchAllSalesforceCrmData, fetchSalesforceCrmCounts } from '../store/salesforceCrmSlice';
|
||||||
|
import {
|
||||||
|
selectDashboardData,
|
||||||
|
selectIsAnyLoading,
|
||||||
|
selectHasAnyError
|
||||||
|
} from '../store/selectors';
|
||||||
|
import type { AppDispatch } from '@/store/store';
|
||||||
|
import { PieChart, DonutChart, StackedBarChart } from '@/shared/components/charts';
|
||||||
|
|
||||||
|
const SalesforceCrmDashboardScreen: React.FC = () => {
|
||||||
|
const { colors, fonts } = useTheme();
|
||||||
|
const navigation = useNavigation();
|
||||||
|
const dispatch = useDispatch<AppDispatch>();
|
||||||
|
|
||||||
|
// Redux selectors
|
||||||
|
const dashboardData = useSelector(selectDashboardData);
|
||||||
|
const isLoading = useSelector(selectIsAnyLoading);
|
||||||
|
const hasError = useSelector(selectHasAnyError);
|
||||||
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
|
|
||||||
|
// Fetch data on component mount
|
||||||
|
useEffect(() => {
|
||||||
|
fetchData();
|
||||||
|
}, [dispatch]);
|
||||||
|
|
||||||
|
// Fetch all Salesforce CRM data
|
||||||
|
const fetchData = async () => {
|
||||||
|
try {
|
||||||
|
await dispatch(fetchAllSalesforceCrmData()).unwrap();
|
||||||
|
await dispatch(fetchSalesforceCrmCounts()).unwrap();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch Salesforce CRM data:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle refresh
|
||||||
|
const handleRefresh = async () => {
|
||||||
|
setRefreshing(true);
|
||||||
|
await fetchData();
|
||||||
|
setRefreshing(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollView
|
||||||
|
style={[styles.container, { backgroundColor: colors.background }]}
|
||||||
|
contentContainerStyle={styles.scrollContent}
|
||||||
|
refreshControl={
|
||||||
|
<RefreshControl refreshing={refreshing} onRefresh={handleRefresh} />
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<View style={styles.header}>
|
||||||
|
<Text style={[styles.title, { color: colors.text, fontFamily: fonts.bold }]}>Salesforce CRM</Text>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.dataButton, { backgroundColor: colors.primary }]}
|
||||||
|
onPress={() => navigation.navigate('SalesforceData' as never)}
|
||||||
|
>
|
||||||
|
<Icon name="database" size={20} color={colors.surface} />
|
||||||
|
<Text style={[styles.dataButtonText, { color: colors.surface, fontFamily: fonts.medium }]}>
|
||||||
|
View Data
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Loading State */}
|
||||||
|
{isLoading && !dashboardData.events.length && (
|
||||||
|
<View style={[styles.loadingCard, { borderColor: colors.primary, backgroundColor: colors.surface }]}>
|
||||||
|
<ActivityIndicator size="large" color={colors.primary} />
|
||||||
|
<Text style={[styles.loadingText, { color: colors.primary, fontFamily: fonts.medium }]}>
|
||||||
|
Loading Salesforce CRM data...
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Error State */}
|
||||||
|
{hasError && !dashboardData.events.length && (
|
||||||
|
<View style={[styles.errorCard, { borderColor: colors.error, backgroundColor: colors.surface }]}>
|
||||||
|
<Icon name="alert-circle" size={20} color={colors.error} />
|
||||||
|
<Text style={[styles.errorText, { color: colors.error, fontFamily: fonts.medium }]}>
|
||||||
|
Failed to load data. Pull to refresh.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Hero Stats - Large Cards */}
|
||||||
|
<View style={styles.heroStatsContainer}>
|
||||||
|
<View style={[styles.heroCard, { backgroundColor: colors.surface, borderColor: colors.border }]}>
|
||||||
|
<View style={styles.heroCardHeader}>
|
||||||
|
<Icon name="account-heart" size={24} color="#3B82F6" />
|
||||||
|
<Text style={[styles.heroCardTitle, { color: colors.text, fontFamily: fonts.medium }]}>Leads</Text>
|
||||||
|
</View>
|
||||||
|
<Text style={[styles.heroCardValue, { color: colors.text, fontFamily: fonts.bold }]}>
|
||||||
|
{dashboardData.counts?.leads || dashboardData.summary.totalLeads}
|
||||||
|
</Text>
|
||||||
|
<Text style={[styles.heroCardSubtitle, { color: colors.textLight, fontFamily: fonts.regular }]}>
|
||||||
|
{dashboardData.summary.newLeads} New · {dashboardData.summary.qualifiedLeads} Qualified
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={[styles.heroCard, { backgroundColor: colors.surface, borderColor: colors.border }]}>
|
||||||
|
<View style={styles.heroCardHeader}>
|
||||||
|
<Icon name="handshake" size={24} color="#10B981" />
|
||||||
|
<Text style={[styles.heroCardTitle, { color: colors.text, fontFamily: fonts.medium }]}>Opportunities</Text>
|
||||||
|
</View>
|
||||||
|
<Text style={[styles.heroCardValue, { color: colors.text, fontFamily: fonts.bold }]}>
|
||||||
|
{dashboardData.counts?.opportunities || dashboardData.summary.totalOpportunities}
|
||||||
|
</Text>
|
||||||
|
<Text style={[styles.heroCardSubtitle, { color: colors.textLight, fontFamily: fonts.regular }]}>
|
||||||
|
{dashboardData.summary.openOpportunities} Open · {dashboardData.summary.wonOpportunities} Won
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Pipeline Value Card */}
|
||||||
|
<View style={[styles.pipelineCard, { backgroundColor: colors.surface, borderColor: colors.border }]}>
|
||||||
|
<View style={styles.pipelineHeader}>
|
||||||
|
<Icon name="trending-up" size={28} color="#10B981" />
|
||||||
|
<Text style={[styles.pipelineTitle, { color: colors.text, fontFamily: fonts.bold }]}>Pipeline Overview</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.pipelineStats}>
|
||||||
|
<View style={styles.pipelineItem}>
|
||||||
|
<Text style={[styles.pipelineLabel, { color: colors.textLight, fontFamily: fonts.regular }]}>Total Pipeline</Text>
|
||||||
|
<Text style={[styles.pipelineValue, { color: colors.text, fontFamily: fonts.bold }]}>
|
||||||
|
${(dashboardData.summary.totalPipelineValue / 1000).toFixed(1)}K
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.pipelineItem}>
|
||||||
|
<Text style={[styles.pipelineLabel, { color: colors.textLight, fontFamily: fonts.regular }]}>Avg Deal Size</Text>
|
||||||
|
<Text style={[styles.pipelineValue, { color: colors.primary, fontFamily: fonts.bold }]}>
|
||||||
|
${(dashboardData.summary.averageDealSize / 1000).toFixed(1)}K
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.pipelineItem}>
|
||||||
|
<Text style={[styles.pipelineLabel, { color: colors.textLight, fontFamily: fonts.regular }]}>Open Deals</Text>
|
||||||
|
<Text style={[styles.pipelineValue, { color: colors.text, fontFamily: fonts.bold }]}>
|
||||||
|
{dashboardData.summary.openOpportunities}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Module Counts - Compact Grid */}
|
||||||
|
<View style={styles.compactGrid}>
|
||||||
|
<View style={[styles.compactCard, { backgroundColor: colors.surface, borderColor: colors.border }]}>
|
||||||
|
<Icon name="calendar" size={20} color="#8B5CF6" />
|
||||||
|
<Text style={[styles.compactValue, { color: colors.text, fontFamily: fonts.bold }]}>
|
||||||
|
{dashboardData.counts?.events || dashboardData.summary.totalEvents}
|
||||||
|
</Text>
|
||||||
|
<Text style={[styles.compactLabel, { color: colors.textLight, fontFamily: fonts.regular }]}>Events</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={[styles.compactCard, { backgroundColor: colors.surface, borderColor: colors.border }]}>
|
||||||
|
<Icon name="check-circle" size={20} color="#10B981" />
|
||||||
|
<Text style={[styles.compactValue, { color: colors.text, fontFamily: fonts.bold }]}>
|
||||||
|
{dashboardData.counts?.tasks || dashboardData.summary.totalTasks}
|
||||||
|
</Text>
|
||||||
|
<Text style={[styles.compactLabel, { color: colors.textLight, fontFamily: fonts.regular }]}>Tasks</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={[styles.compactCard, { backgroundColor: colors.surface, borderColor: colors.border }]}>
|
||||||
|
<Icon name="domain" size={20} color="#F59E0B" />
|
||||||
|
<Text style={[styles.compactValue, { color: colors.text, fontFamily: fonts.bold }]}>
|
||||||
|
{dashboardData.counts?.accounts || dashboardData.summary.totalAccounts}
|
||||||
|
</Text>
|
||||||
|
<Text style={[styles.compactLabel, { color: colors.textLight, fontFamily: fonts.regular }]}>Accounts</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Lead Status Distribution */}
|
||||||
|
<View style={[styles.card, { borderColor: colors.border, backgroundColor: colors.surface }]}>
|
||||||
|
<Text style={[styles.cardTitle, { color: colors.text, fontFamily: fonts.medium }]}>Lead Status Distribution</Text>
|
||||||
|
|
||||||
|
<View style={styles.chartContainer}>
|
||||||
|
<PieChart
|
||||||
|
data={[
|
||||||
|
{ label: 'New', value: dashboardData.summary.newLeads, color: '#3AA0FF' },
|
||||||
|
{ label: 'Working', value: dashboardData.summary.workingLeads, color: '#F59E0B' },
|
||||||
|
{ label: 'Qualified', value: dashboardData.summary.qualifiedLeads, color: '#22C55E' },
|
||||||
|
].filter(item => item.value > 0)}
|
||||||
|
colors={colors}
|
||||||
|
fonts={fonts}
|
||||||
|
size={140}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Legend */}
|
||||||
|
<View style={styles.pieLegend}>
|
||||||
|
{[
|
||||||
|
{ label: 'New', value: dashboardData.summary.newLeads, color: '#3AA0FF' },
|
||||||
|
{ label: 'Working', value: dashboardData.summary.workingLeads, color: '#F59E0B' },
|
||||||
|
{ label: 'Qualified', value: dashboardData.summary.qualifiedLeads, color: '#22C55E' },
|
||||||
|
].filter(item => item.value > 0).map((item) => (
|
||||||
|
<View key={item.label} style={styles.legendItem}>
|
||||||
|
<View style={[styles.legendDot, { backgroundColor: item.color }]} />
|
||||||
|
<Text style={[styles.legendText, { color: colors.textLight, fontFamily: fonts.regular }]}>
|
||||||
|
{item.label} ({item.value})
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Task Status */}
|
||||||
|
<View style={[styles.card, { borderColor: colors.border, backgroundColor: colors.surface }]}>
|
||||||
|
<Text style={[styles.cardTitle, { color: colors.text, fontFamily: fonts.medium }]}>Task Status</Text>
|
||||||
|
|
||||||
|
<View style={styles.chartContainer}>
|
||||||
|
<StackedBarChart
|
||||||
|
data={[
|
||||||
|
{ label: 'Open', value: dashboardData.summary.openTasks, color: '#3AA0FF' },
|
||||||
|
{ label: 'Completed', value: dashboardData.summary.completedTasks, color: '#22C55E' },
|
||||||
|
{ label: 'High Priority', value: dashboardData.summary.highPriorityTasks, color: '#EF4444' },
|
||||||
|
].filter(item => item.value > 0)}
|
||||||
|
colors={colors}
|
||||||
|
fonts={fonts}
|
||||||
|
height={120}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Legend */}
|
||||||
|
<View style={styles.barLegend}>
|
||||||
|
{[
|
||||||
|
{ label: 'Open', value: dashboardData.summary.openTasks, color: '#3AA0FF' },
|
||||||
|
{ label: 'Completed', value: dashboardData.summary.completedTasks, color: '#22C55E' },
|
||||||
|
{ label: 'High Priority', value: dashboardData.summary.highPriorityTasks, color: '#EF4444' },
|
||||||
|
].filter(item => item.value > 0).map((item) => (
|
||||||
|
<View key={item.label} style={styles.legendItem}>
|
||||||
|
<View style={[styles.legendDot, { backgroundColor: item.color }]} />
|
||||||
|
<Text style={[styles.legendText, { color: colors.textLight, fontFamily: fonts.regular }]}>
|
||||||
|
{item.label} ({item.value})
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Key Metrics */}
|
||||||
|
<View style={styles.metricsRow}>
|
||||||
|
<View style={[styles.metricCard, { backgroundColor: colors.surface, borderColor: colors.border }]}>
|
||||||
|
<Icon name="calendar-clock" size={24} color="#F59E0B" />
|
||||||
|
<Text style={[styles.metricValue, { color: colors.text, fontFamily: fonts.bold }]}>
|
||||||
|
{dashboardData.summary.upcomingEvents}
|
||||||
|
</Text>
|
||||||
|
<Text style={[styles.metricLabel, { color: colors.textLight, fontFamily: fonts.regular }]}>Upcoming Events</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={[styles.metricCard, { backgroundColor: colors.surface, borderColor: colors.border }]}>
|
||||||
|
<Icon name="trophy" size={24} color="#22C55E" />
|
||||||
|
<Text style={[styles.metricValue, { color: colors.text, fontFamily: fonts.bold }]}>
|
||||||
|
{dashboardData.summary.wonOpportunities}
|
||||||
|
</Text>
|
||||||
|
<Text style={[styles.metricLabel, { color: colors.textLight, fontFamily: fonts.regular }]}>Won Deals</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
padding: 16,
|
||||||
|
},
|
||||||
|
scrollContent: {
|
||||||
|
paddingBottom: 20,
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginBottom: 16,
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
fontSize: 24,
|
||||||
|
},
|
||||||
|
dataButton: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
paddingVertical: 8,
|
||||||
|
borderRadius: 20,
|
||||||
|
},
|
||||||
|
dataButtonText: {
|
||||||
|
marginLeft: 6,
|
||||||
|
fontSize: 14,
|
||||||
|
},
|
||||||
|
loadingCard: {
|
||||||
|
borderRadius: 12,
|
||||||
|
borderWidth: 1,
|
||||||
|
padding: 20,
|
||||||
|
marginBottom: 16,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
loadingText: {
|
||||||
|
marginTop: 12,
|
||||||
|
fontSize: 14,
|
||||||
|
textAlign: 'center',
|
||||||
|
},
|
||||||
|
errorCard: {
|
||||||
|
borderRadius: 12,
|
||||||
|
borderWidth: 1,
|
||||||
|
padding: 12,
|
||||||
|
marginBottom: 16,
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
errorText: {
|
||||||
|
marginLeft: 8,
|
||||||
|
fontSize: 14,
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
heroStatsContainer: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
marginBottom: 16,
|
||||||
|
},
|
||||||
|
heroCard: {
|
||||||
|
flex: 1,
|
||||||
|
marginHorizontal: 4,
|
||||||
|
padding: 20,
|
||||||
|
borderRadius: 16,
|
||||||
|
borderWidth: 1,
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
heroCardHeader: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginBottom: 12,
|
||||||
|
},
|
||||||
|
heroCardTitle: {
|
||||||
|
fontSize: 16,
|
||||||
|
marginLeft: 8,
|
||||||
|
},
|
||||||
|
heroCardValue: {
|
||||||
|
fontSize: 32,
|
||||||
|
marginBottom: 4,
|
||||||
|
},
|
||||||
|
heroCardSubtitle: {
|
||||||
|
fontSize: 12,
|
||||||
|
textAlign: 'center',
|
||||||
|
},
|
||||||
|
pipelineCard: {
|
||||||
|
padding: 20,
|
||||||
|
borderRadius: 16,
|
||||||
|
borderWidth: 1,
|
||||||
|
marginBottom: 16,
|
||||||
|
},
|
||||||
|
pipelineHeader: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginBottom: 16,
|
||||||
|
},
|
||||||
|
pipelineTitle: {
|
||||||
|
fontSize: 20,
|
||||||
|
marginLeft: 12,
|
||||||
|
},
|
||||||
|
pipelineStats: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
},
|
||||||
|
pipelineItem: {
|
||||||
|
flex: 1,
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
pipelineLabel: {
|
||||||
|
fontSize: 12,
|
||||||
|
marginBottom: 4,
|
||||||
|
},
|
||||||
|
pipelineValue: {
|
||||||
|
fontSize: 18,
|
||||||
|
},
|
||||||
|
compactGrid: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
marginBottom: 16,
|
||||||
|
},
|
||||||
|
compactCard: {
|
||||||
|
width: '30%',
|
||||||
|
padding: 12,
|
||||||
|
borderRadius: 12,
|
||||||
|
borderWidth: 1,
|
||||||
|
alignItems: 'center',
|
||||||
|
marginBottom: 8,
|
||||||
|
},
|
||||||
|
compactValue: {
|
||||||
|
fontSize: 18,
|
||||||
|
marginVertical: 4,
|
||||||
|
},
|
||||||
|
compactLabel: {
|
||||||
|
fontSize: 10,
|
||||||
|
textAlign: 'center',
|
||||||
|
},
|
||||||
|
card: {
|
||||||
|
borderRadius: 12,
|
||||||
|
borderWidth: 1,
|
||||||
|
padding: 16,
|
||||||
|
marginBottom: 16,
|
||||||
|
},
|
||||||
|
cardTitle: {
|
||||||
|
fontSize: 16,
|
||||||
|
marginBottom: 12,
|
||||||
|
},
|
||||||
|
chartContainer: {
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
paddingVertical: 10,
|
||||||
|
},
|
||||||
|
pieLegend: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
justifyContent: 'center',
|
||||||
|
marginTop: 12,
|
||||||
|
gap: 8,
|
||||||
|
},
|
||||||
|
barLegend: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
justifyContent: 'center',
|
||||||
|
marginTop: 8,
|
||||||
|
gap: 6,
|
||||||
|
},
|
||||||
|
legendItem: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginRight: 12,
|
||||||
|
marginTop: 6,
|
||||||
|
},
|
||||||
|
legendDot: {
|
||||||
|
width: 8,
|
||||||
|
height: 8,
|
||||||
|
borderRadius: 4,
|
||||||
|
marginRight: 6,
|
||||||
|
},
|
||||||
|
legendText: {
|
||||||
|
fontSize: 12,
|
||||||
|
},
|
||||||
|
metricsRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
marginBottom: 16,
|
||||||
|
},
|
||||||
|
metricCard: {
|
||||||
|
flex: 1,
|
||||||
|
marginHorizontal: 4,
|
||||||
|
padding: 16,
|
||||||
|
borderRadius: 12,
|
||||||
|
borderWidth: 1,
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
metricValue: {
|
||||||
|
fontSize: 20,
|
||||||
|
marginVertical: 8,
|
||||||
|
},
|
||||||
|
metricLabel: {
|
||||||
|
fontSize: 12,
|
||||||
|
textAlign: 'center',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default SalesforceCrmDashboardScreen;
|
||||||
|
|
||||||
473
src/modules/crm/salesforce/screens/SalesforceDataScreen.tsx
Normal file
473
src/modules/crm/salesforce/screens/SalesforceDataScreen.tsx
Normal file
@ -0,0 +1,473 @@
|
|||||||
|
import React, { useState, useEffect, useMemo, useCallback } from 'react';
|
||||||
|
import {
|
||||||
|
View,
|
||||||
|
Text,
|
||||||
|
StyleSheet,
|
||||||
|
ScrollView,
|
||||||
|
TouchableOpacity,
|
||||||
|
RefreshControl,
|
||||||
|
FlatList,
|
||||||
|
ActivityIndicator,
|
||||||
|
} from 'react-native';
|
||||||
|
import { useSelector, useDispatch } from 'react-redux';
|
||||||
|
import type { AppDispatch } from '@/store/store';
|
||||||
|
import Icon from 'react-native-vector-icons/MaterialCommunityIcons';
|
||||||
|
import { LoadingSpinner, ErrorState } from '@/shared/components/ui';
|
||||||
|
import { useTheme } from '@/shared/styles/useTheme';
|
||||||
|
import { showError, showSuccess, showInfo } from '@/shared/utils/Toast';
|
||||||
|
import { EventCard, LeadCard, TaskCard, AccountCard, OpportunityCard } from '../components/SalesforceDataCards';
|
||||||
|
import {
|
||||||
|
selectSalesforceEvents,
|
||||||
|
selectSalesforceLeads,
|
||||||
|
selectSalesforceTasks,
|
||||||
|
selectSalesforceAccounts,
|
||||||
|
selectSalesforceOpportunities,
|
||||||
|
selectSalesforceCrmLoading,
|
||||||
|
selectSalesforceCrmErrors,
|
||||||
|
selectEventsPagination,
|
||||||
|
selectLeadsPagination,
|
||||||
|
selectTasksPagination,
|
||||||
|
selectAccountsPagination,
|
||||||
|
selectOpportunitiesPagination,
|
||||||
|
selectSalesforceCrmCounts
|
||||||
|
} from '../store/selectors';
|
||||||
|
import {
|
||||||
|
fetchAllSalesforceCrmData,
|
||||||
|
fetchSalesforceEvents,
|
||||||
|
fetchSalesforceLeads,
|
||||||
|
fetchSalesforceTasks,
|
||||||
|
fetchSalesforceAccounts,
|
||||||
|
fetchSalesforceOpportunities,
|
||||||
|
fetchSalesforceCrmCounts,
|
||||||
|
resetEventsPagination,
|
||||||
|
resetLeadsPagination,
|
||||||
|
resetTasksPagination,
|
||||||
|
resetAccountsPagination,
|
||||||
|
resetOpportunitiesPagination
|
||||||
|
} from '../store/salesforceCrmSlice';
|
||||||
|
|
||||||
|
const SalesforceDataScreen: React.FC = () => {
|
||||||
|
const { colors, fonts } = useTheme();
|
||||||
|
const dispatch = useDispatch<AppDispatch>();
|
||||||
|
const [selectedTab, setSelectedTab] = useState<'events' | 'leads' | 'tasks' | 'accounts' | 'opportunities'>('leads');
|
||||||
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
|
|
||||||
|
// Redux selectors
|
||||||
|
const events = useSelector(selectSalesforceEvents);
|
||||||
|
const leads = useSelector(selectSalesforceLeads);
|
||||||
|
const tasks = useSelector(selectSalesforceTasks);
|
||||||
|
const accounts = useSelector(selectSalesforceAccounts);
|
||||||
|
const opportunities = useSelector(selectSalesforceOpportunities);
|
||||||
|
const loading = useSelector(selectSalesforceCrmLoading);
|
||||||
|
const errors = useSelector(selectSalesforceCrmErrors);
|
||||||
|
|
||||||
|
// Pagination selectors
|
||||||
|
const eventsPagination = useSelector(selectEventsPagination);
|
||||||
|
const leadsPagination = useSelector(selectLeadsPagination);
|
||||||
|
const tasksPagination = useSelector(selectTasksPagination);
|
||||||
|
const accountsPagination = useSelector(selectAccountsPagination);
|
||||||
|
const opportunitiesPagination = useSelector(selectOpportunitiesPagination);
|
||||||
|
const counts = useSelector(selectSalesforceCrmCounts);
|
||||||
|
|
||||||
|
// Fetch Salesforce CRM data
|
||||||
|
const fetchSalesforceData = useCallback(async (showRefresh = false) => {
|
||||||
|
try {
|
||||||
|
if (showRefresh) {
|
||||||
|
setRefreshing(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dispatch Redux action to fetch all Salesforce CRM data
|
||||||
|
await dispatch(fetchAllSalesforceCrmData()).unwrap();
|
||||||
|
|
||||||
|
if (showRefresh) {
|
||||||
|
showSuccess('Salesforce data refreshed successfully');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
const errorMessage = 'Failed to fetch Salesforce data';
|
||||||
|
showError(errorMessage);
|
||||||
|
} finally {
|
||||||
|
setRefreshing(false);
|
||||||
|
}
|
||||||
|
}, [dispatch]);
|
||||||
|
|
||||||
|
// Load more data for infinite scrolling
|
||||||
|
const loadMoreData = useCallback(async (dataType: string) => {
|
||||||
|
try {
|
||||||
|
let pagination;
|
||||||
|
let fetchAction;
|
||||||
|
|
||||||
|
switch (dataType) {
|
||||||
|
case 'events':
|
||||||
|
pagination = eventsPagination;
|
||||||
|
fetchAction = fetchSalesforceEvents;
|
||||||
|
break;
|
||||||
|
case 'leads':
|
||||||
|
pagination = leadsPagination;
|
||||||
|
fetchAction = fetchSalesforceLeads;
|
||||||
|
break;
|
||||||
|
case 'tasks':
|
||||||
|
pagination = tasksPagination;
|
||||||
|
fetchAction = fetchSalesforceTasks;
|
||||||
|
break;
|
||||||
|
case 'accounts':
|
||||||
|
pagination = accountsPagination;
|
||||||
|
fetchAction = fetchSalesforceAccounts;
|
||||||
|
break;
|
||||||
|
case 'opportunities':
|
||||||
|
pagination = opportunitiesPagination;
|
||||||
|
fetchAction = fetchSalesforceOpportunities;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if there are more records and not currently loading
|
||||||
|
if (!pagination.moreRecords || loading[dataType as keyof typeof loading]) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch next page
|
||||||
|
await (dispatch(fetchAction({
|
||||||
|
page: pagination.page + 1,
|
||||||
|
limit: 20,
|
||||||
|
append: true
|
||||||
|
}) as any)).unwrap();
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
showError(`Failed to load more ${dataType}`);
|
||||||
|
}
|
||||||
|
}, [dispatch, eventsPagination, leadsPagination, tasksPagination, accountsPagination, opportunitiesPagination, loading]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchSalesforceData();
|
||||||
|
// Fetch counts in parallel
|
||||||
|
dispatch(fetchSalesforceCrmCounts());
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleRefresh = useCallback(() => {
|
||||||
|
// Reset pagination for all data types before refreshing
|
||||||
|
dispatch(resetEventsPagination());
|
||||||
|
dispatch(resetLeadsPagination());
|
||||||
|
dispatch(resetTasksPagination());
|
||||||
|
dispatch(resetAccountsPagination());
|
||||||
|
dispatch(resetOpportunitiesPagination());
|
||||||
|
|
||||||
|
// Then fetch fresh data and counts
|
||||||
|
fetchSalesforceData(true);
|
||||||
|
dispatch(fetchSalesforceCrmCounts());
|
||||||
|
}, [fetchSalesforceData, dispatch]);
|
||||||
|
|
||||||
|
const handleRetry = useCallback(() => {
|
||||||
|
fetchSalesforceData();
|
||||||
|
}, [fetchSalesforceData]);
|
||||||
|
|
||||||
|
const handleCardPress = useCallback((item: any, type: string) => {
|
||||||
|
showInfo(`Viewing ${type}: ${item.Name || item.Subject || `${item.FirstName} ${item.LastName}`}`);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Render loading footer for infinite scroll
|
||||||
|
const renderFooter = useCallback((dataType: string) => {
|
||||||
|
const isLoadingMore = loading[dataType as keyof typeof loading];
|
||||||
|
if (!isLoadingMore) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.footerLoader}>
|
||||||
|
<ActivityIndicator size="small" color={colors.primary} />
|
||||||
|
<Text style={[styles.footerText, { color: colors.textLight }]}>
|
||||||
|
Loading more...
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}, [loading, colors]);
|
||||||
|
|
||||||
|
// Get current loading state and error
|
||||||
|
const isLoading = loading.events || loading.leads || loading.tasks || loading.accounts || loading.opportunities;
|
||||||
|
const hasError = errors.events || errors.leads || errors.tasks || errors.accounts || errors.opportunities;
|
||||||
|
|
||||||
|
// Tab configuration with counts from API
|
||||||
|
const tabs = [
|
||||||
|
{
|
||||||
|
key: 'events',
|
||||||
|
label: 'Events',
|
||||||
|
icon: 'calendar',
|
||||||
|
count: counts?.events || events.length
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'leads',
|
||||||
|
label: 'Leads',
|
||||||
|
icon: 'account-heart',
|
||||||
|
count: counts?.leads || leads.length
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'tasks',
|
||||||
|
label: 'Tasks',
|
||||||
|
icon: 'check-circle',
|
||||||
|
count: counts?.tasks || tasks.length
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'accounts',
|
||||||
|
label: 'Accounts',
|
||||||
|
icon: 'domain',
|
||||||
|
count: counts?.accounts || accounts.length
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'opportunities',
|
||||||
|
label: 'Opportunities',
|
||||||
|
icon: 'handshake',
|
||||||
|
count: counts?.opportunities || opportunities.length
|
||||||
|
},
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const renderTabContent = useCallback(() => {
|
||||||
|
const commonFlatListProps = {
|
||||||
|
numColumns: 1,
|
||||||
|
showsVerticalScrollIndicator: false,
|
||||||
|
contentContainerStyle: styles.listContainer,
|
||||||
|
refreshControl: (
|
||||||
|
<RefreshControl refreshing={refreshing} onRefresh={handleRefresh} />
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
switch (selectedTab) {
|
||||||
|
case 'events':
|
||||||
|
return (
|
||||||
|
<FlatList
|
||||||
|
data={events}
|
||||||
|
renderItem={({ item }) => (
|
||||||
|
<EventCard
|
||||||
|
event={item}
|
||||||
|
onPress={() => handleCardPress(item, 'Event')}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
keyExtractor={(item) => `event-${item.Id}`}
|
||||||
|
onEndReached={() => loadMoreData('events')}
|
||||||
|
onEndReachedThreshold={0.1}
|
||||||
|
ListFooterComponent={() => renderFooter('events')}
|
||||||
|
{...commonFlatListProps}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
case 'leads':
|
||||||
|
return (
|
||||||
|
<FlatList
|
||||||
|
data={leads}
|
||||||
|
renderItem={({ item }) => (
|
||||||
|
<LeadCard
|
||||||
|
lead={item}
|
||||||
|
onPress={() => handleCardPress(item, 'Lead')}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
keyExtractor={(item) => `lead-${item.Id}`}
|
||||||
|
onEndReached={() => loadMoreData('leads')}
|
||||||
|
onEndReachedThreshold={0.1}
|
||||||
|
ListFooterComponent={() => renderFooter('leads')}
|
||||||
|
{...commonFlatListProps}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
case 'tasks':
|
||||||
|
return (
|
||||||
|
<FlatList
|
||||||
|
data={tasks}
|
||||||
|
renderItem={({ item }) => (
|
||||||
|
<TaskCard
|
||||||
|
task={item}
|
||||||
|
onPress={() => handleCardPress(item, 'Task')}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
keyExtractor={(item) => `task-${item.Id}`}
|
||||||
|
onEndReached={() => loadMoreData('tasks')}
|
||||||
|
onEndReachedThreshold={0.1}
|
||||||
|
ListFooterComponent={() => renderFooter('tasks')}
|
||||||
|
{...commonFlatListProps}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
case 'accounts':
|
||||||
|
return (
|
||||||
|
<FlatList
|
||||||
|
data={accounts}
|
||||||
|
renderItem={({ item }) => (
|
||||||
|
<AccountCard
|
||||||
|
account={item}
|
||||||
|
onPress={() => handleCardPress(item, 'Account')}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
keyExtractor={(item) => `account-${item.Id}`}
|
||||||
|
onEndReached={() => loadMoreData('accounts')}
|
||||||
|
onEndReachedThreshold={0.1}
|
||||||
|
ListFooterComponent={() => renderFooter('accounts')}
|
||||||
|
{...commonFlatListProps}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
case 'opportunities':
|
||||||
|
return (
|
||||||
|
<FlatList
|
||||||
|
data={opportunities}
|
||||||
|
renderItem={({ item }) => (
|
||||||
|
<OpportunityCard
|
||||||
|
opportunity={item}
|
||||||
|
onPress={() => handleCardPress(item, 'Opportunity')}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
keyExtractor={(item) => `opportunity-${item.Id}`}
|
||||||
|
onEndReached={() => loadMoreData('opportunities')}
|
||||||
|
onEndReachedThreshold={0.1}
|
||||||
|
ListFooterComponent={() => renderFooter('opportunities')}
|
||||||
|
{...commonFlatListProps}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}, [selectedTab, events, leads, tasks, accounts, opportunities, handleCardPress, loadMoreData, renderFooter, refreshing, handleRefresh]);
|
||||||
|
|
||||||
|
// Conditional returns after all hooks
|
||||||
|
if (isLoading && !leads.length) {
|
||||||
|
return <LoadingSpinner />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasError && !leads.length) {
|
||||||
|
return <ErrorState onRetry={handleRetry} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={[styles.container, { backgroundColor: colors.background }]}>
|
||||||
|
{/* Fixed Header */}
|
||||||
|
<View style={styles.header}>
|
||||||
|
<Text style={[styles.title, { color: colors.text, fontFamily: fonts.bold }]}>
|
||||||
|
Salesforce CRM Data
|
||||||
|
</Text>
|
||||||
|
<TouchableOpacity onPress={handleRefresh} disabled={refreshing}>
|
||||||
|
<Icon name="refresh" size={24} color={colors.primary} />
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Fixed Tabs */}
|
||||||
|
<View style={styles.tabsContainer}>
|
||||||
|
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
|
||||||
|
<View style={styles.tabs}>
|
||||||
|
{tabs.map((tab) => (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={tab.key}
|
||||||
|
style={[
|
||||||
|
styles.tab,
|
||||||
|
selectedTab === tab.key && { backgroundColor: colors.primary },
|
||||||
|
]}
|
||||||
|
onPress={() => setSelectedTab(tab.key)}
|
||||||
|
activeOpacity={0.8}
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
name={tab.icon}
|
||||||
|
size={20}
|
||||||
|
color={selectedTab === tab.key ? colors.surface : colors.textLight}
|
||||||
|
/>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.tabText,
|
||||||
|
{
|
||||||
|
color: selectedTab === tab.key ? colors.surface : colors.textLight,
|
||||||
|
fontFamily: fonts.medium,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{tab.label}
|
||||||
|
</Text>
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.countBadge,
|
||||||
|
{ backgroundColor: selectedTab === tab.key ? colors.surface : colors.primary },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.countText,
|
||||||
|
{
|
||||||
|
color: selectedTab === tab.key ? colors.primary : colors.surface,
|
||||||
|
fontFamily: fonts.bold,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{tab.count}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Scrollable Content */}
|
||||||
|
<View style={styles.content}>
|
||||||
|
{renderTabContent()}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
paddingVertical: 12,
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
fontSize: 24,
|
||||||
|
},
|
||||||
|
tabsContainer: {
|
||||||
|
backgroundColor: '#FFFFFF',
|
||||||
|
paddingVertical: 12,
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: '#E5E7EB',
|
||||||
|
},
|
||||||
|
tabs: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
},
|
||||||
|
tab: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
paddingVertical: 10,
|
||||||
|
marginRight: 8,
|
||||||
|
borderRadius: 20,
|
||||||
|
backgroundColor: '#F1F5F9',
|
||||||
|
},
|
||||||
|
tabText: {
|
||||||
|
marginLeft: 6,
|
||||||
|
fontSize: 14,
|
||||||
|
},
|
||||||
|
countBadge: {
|
||||||
|
marginLeft: 6,
|
||||||
|
paddingHorizontal: 8,
|
||||||
|
paddingVertical: 2,
|
||||||
|
borderRadius: 10,
|
||||||
|
minWidth: 20,
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
countText: {
|
||||||
|
fontSize: 12,
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
listContainer: {
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
paddingBottom: 20,
|
||||||
|
},
|
||||||
|
footerLoader: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingVertical: 16,
|
||||||
|
},
|
||||||
|
footerText: {
|
||||||
|
marginLeft: 8,
|
||||||
|
fontSize: 14,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default SalesforceDataScreen;
|
||||||
|
|
||||||
65
src/modules/crm/salesforce/services/salesforceCrmAPI.ts
Normal file
65
src/modules/crm/salesforce/services/salesforceCrmAPI.ts
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
import http from '@/services/http';
|
||||||
|
import type {
|
||||||
|
SalesforceEvent,
|
||||||
|
SalesforceLead,
|
||||||
|
SalesforceTask,
|
||||||
|
SalesforceAccount,
|
||||||
|
SalesforceOpportunity,
|
||||||
|
SalesforceSearchParams,
|
||||||
|
SalesforceApiResponse
|
||||||
|
} from '../types/SalesforceCrmTypes';
|
||||||
|
|
||||||
|
// Available Salesforce CRM resource types
|
||||||
|
export type SalesforceResourceType = 'events' | 'leads' | 'tasks' | 'accounts' | 'opportunities';
|
||||||
|
|
||||||
|
// Base API endpoint for Salesforce CRM
|
||||||
|
const SALESFORCE_CRM_BASE_URL = '/api/v1/n8n/salesforce/crm';
|
||||||
|
|
||||||
|
export const salesforceCrmAPI = {
|
||||||
|
// Generic method to get Salesforce CRM data by resource type
|
||||||
|
getSalesforceCrmData: <T = any>(
|
||||||
|
resource: SalesforceResourceType,
|
||||||
|
params?: SalesforceSearchParams
|
||||||
|
) => {
|
||||||
|
const queryParams = {
|
||||||
|
page: params?.page || 1,
|
||||||
|
limit: params?.limit || 20,
|
||||||
|
...params
|
||||||
|
};
|
||||||
|
|
||||||
|
return http.get<SalesforceApiResponse<T>>(`${SALESFORCE_CRM_BASE_URL}/${resource}`, queryParams);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Specific resource methods for type safety
|
||||||
|
getEvents: (params?: SalesforceSearchParams) =>
|
||||||
|
salesforceCrmAPI.getSalesforceCrmData<SalesforceEvent>('events', params),
|
||||||
|
|
||||||
|
getLeads: (params?: SalesforceSearchParams) =>
|
||||||
|
salesforceCrmAPI.getSalesforceCrmData<SalesforceLead>('leads', params),
|
||||||
|
|
||||||
|
getTasks: (params?: SalesforceSearchParams) =>
|
||||||
|
salesforceCrmAPI.getSalesforceCrmData<SalesforceTask>('tasks', params),
|
||||||
|
|
||||||
|
getAccounts: (params?: SalesforceSearchParams) =>
|
||||||
|
salesforceCrmAPI.getSalesforceCrmData<SalesforceAccount>('accounts', params),
|
||||||
|
|
||||||
|
getOpportunities: (params?: SalesforceSearchParams) =>
|
||||||
|
salesforceCrmAPI.getSalesforceCrmData<SalesforceOpportunity>('opportunities', params),
|
||||||
|
|
||||||
|
// Get counts for all Salesforce CRM modules
|
||||||
|
getSalesforceCrmCounts: () => {
|
||||||
|
return http.get<{
|
||||||
|
status: string;
|
||||||
|
message: string;
|
||||||
|
data: {
|
||||||
|
events: { count: number; success: boolean };
|
||||||
|
leads: { count: number; success: boolean };
|
||||||
|
tasks: { count: number; success: boolean };
|
||||||
|
accounts: { count: number; success: boolean };
|
||||||
|
opportunities: { count: number; success: boolean };
|
||||||
|
};
|
||||||
|
timestamp: string;
|
||||||
|
}>(`${SALESFORCE_CRM_BASE_URL}/counts`);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
517
src/modules/crm/salesforce/store/salesforceCrmSlice.ts
Normal file
517
src/modules/crm/salesforce/store/salesforceCrmSlice.ts
Normal file
@ -0,0 +1,517 @@
|
|||||||
|
import { createSlice, createAsyncThunk, PayloadAction } from '@reduxjs/toolkit';
|
||||||
|
import { salesforceCrmAPI } from '../services/salesforceCrmAPI';
|
||||||
|
import type {
|
||||||
|
SalesforceEvent,
|
||||||
|
SalesforceLead,
|
||||||
|
SalesforceTask,
|
||||||
|
SalesforceAccount,
|
||||||
|
SalesforceOpportunity,
|
||||||
|
SalesforceStats,
|
||||||
|
SalesforceSearchParams
|
||||||
|
} from '../types/SalesforceCrmTypes';
|
||||||
|
|
||||||
|
// State interface
|
||||||
|
export interface SalesforceCrmState {
|
||||||
|
// Data
|
||||||
|
events: SalesforceEvent[];
|
||||||
|
leads: SalesforceLead[];
|
||||||
|
tasks: SalesforceTask[];
|
||||||
|
accounts: SalesforceAccount[];
|
||||||
|
opportunities: SalesforceOpportunity[];
|
||||||
|
|
||||||
|
// Loading states
|
||||||
|
loading: {
|
||||||
|
events: boolean;
|
||||||
|
leads: boolean;
|
||||||
|
tasks: boolean;
|
||||||
|
accounts: boolean;
|
||||||
|
opportunities: boolean;
|
||||||
|
stats: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Error states
|
||||||
|
errors: {
|
||||||
|
events: string | null;
|
||||||
|
leads: string | null;
|
||||||
|
tasks: string | null;
|
||||||
|
accounts: string | null;
|
||||||
|
opportunities: string | null;
|
||||||
|
stats: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Pagination
|
||||||
|
pagination: {
|
||||||
|
events: { page: number; count: number; moreRecords: boolean };
|
||||||
|
leads: { page: number; count: number; moreRecords: boolean };
|
||||||
|
tasks: { page: number; count: number; moreRecords: boolean };
|
||||||
|
accounts: { page: number; count: number; moreRecords: boolean };
|
||||||
|
opportunities: { page: number; count: number; moreRecords: boolean };
|
||||||
|
};
|
||||||
|
|
||||||
|
// Statistics
|
||||||
|
stats: SalesforceStats | null;
|
||||||
|
|
||||||
|
// Module counts
|
||||||
|
counts: {
|
||||||
|
events: number;
|
||||||
|
leads: number;
|
||||||
|
tasks: number;
|
||||||
|
accounts: number;
|
||||||
|
opportunities: number;
|
||||||
|
} | null;
|
||||||
|
|
||||||
|
// Last updated timestamps
|
||||||
|
lastUpdated: {
|
||||||
|
events: string | null;
|
||||||
|
leads: string | null;
|
||||||
|
tasks: string | null;
|
||||||
|
accounts: string | null;
|
||||||
|
opportunities: string | null;
|
||||||
|
stats: string | null;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initial state
|
||||||
|
const initialState: SalesforceCrmState = {
|
||||||
|
events: [],
|
||||||
|
leads: [],
|
||||||
|
tasks: [],
|
||||||
|
accounts: [],
|
||||||
|
opportunities: [],
|
||||||
|
loading: {
|
||||||
|
events: false,
|
||||||
|
leads: false,
|
||||||
|
tasks: false,
|
||||||
|
accounts: false,
|
||||||
|
opportunities: false,
|
||||||
|
stats: false,
|
||||||
|
},
|
||||||
|
errors: {
|
||||||
|
events: null,
|
||||||
|
leads: null,
|
||||||
|
tasks: null,
|
||||||
|
accounts: null,
|
||||||
|
opportunities: null,
|
||||||
|
stats: null,
|
||||||
|
},
|
||||||
|
pagination: {
|
||||||
|
events: { page: 1, count: 0, moreRecords: false },
|
||||||
|
leads: { page: 1, count: 0, moreRecords: false },
|
||||||
|
tasks: { page: 1, count: 0, moreRecords: false },
|
||||||
|
accounts: { page: 1, count: 0, moreRecords: false },
|
||||||
|
opportunities: { page: 1, count: 0, moreRecords: false },
|
||||||
|
},
|
||||||
|
stats: null,
|
||||||
|
counts: null,
|
||||||
|
lastUpdated: {
|
||||||
|
events: null,
|
||||||
|
leads: null,
|
||||||
|
tasks: null,
|
||||||
|
accounts: null,
|
||||||
|
opportunities: null,
|
||||||
|
stats: null,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Async thunks for infinite scrolling
|
||||||
|
export const fetchSalesforceEvents = createAsyncThunk(
|
||||||
|
'salesforceCrm/fetchEvents',
|
||||||
|
async (params?: SalesforceSearchParams & { append?: boolean }) => {
|
||||||
|
const response = await salesforceCrmAPI.getEvents(params);
|
||||||
|
return {
|
||||||
|
data: response.data?.data?.data || [],
|
||||||
|
count: response.data?.data?.count || 0,
|
||||||
|
moreRecords: !response.data?.data?.metadata?.done,
|
||||||
|
append: params?.append || false
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export const fetchSalesforceLeads = createAsyncThunk(
|
||||||
|
'salesforceCrm/fetchLeads',
|
||||||
|
async (params?: SalesforceSearchParams & { append?: boolean }) => {
|
||||||
|
const response = await salesforceCrmAPI.getLeads(params);
|
||||||
|
return {
|
||||||
|
data: response.data?.data?.data || [],
|
||||||
|
count: response.data?.data?.count || 0,
|
||||||
|
moreRecords: !response.data?.data?.metadata?.done,
|
||||||
|
append: params?.append || false
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export const fetchSalesforceTasks = createAsyncThunk(
|
||||||
|
'salesforceCrm/fetchTasks',
|
||||||
|
async (params?: SalesforceSearchParams & { append?: boolean }) => {
|
||||||
|
const response = await salesforceCrmAPI.getTasks(params);
|
||||||
|
return {
|
||||||
|
data: response.data?.data?.data || [],
|
||||||
|
count: response.data?.data?.count || 0,
|
||||||
|
moreRecords: !response.data?.data?.metadata?.done,
|
||||||
|
append: params?.append || false
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export const fetchSalesforceAccounts = createAsyncThunk(
|
||||||
|
'salesforceCrm/fetchAccounts',
|
||||||
|
async (params?: SalesforceSearchParams & { append?: boolean }) => {
|
||||||
|
const response = await salesforceCrmAPI.getAccounts(params);
|
||||||
|
return {
|
||||||
|
data: response.data?.data?.data || [],
|
||||||
|
count: response.data?.data?.count || 0,
|
||||||
|
moreRecords: !response.data?.data?.metadata?.done,
|
||||||
|
append: params?.append || false
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export const fetchSalesforceOpportunities = createAsyncThunk(
|
||||||
|
'salesforceCrm/fetchOpportunities',
|
||||||
|
async (params?: SalesforceSearchParams & { append?: boolean }) => {
|
||||||
|
const response = await salesforceCrmAPI.getOpportunities(params);
|
||||||
|
return {
|
||||||
|
data: response.data?.data?.data || [],
|
||||||
|
count: response.data?.data?.count || 0,
|
||||||
|
moreRecords: !response.data?.data?.metadata?.done,
|
||||||
|
append: params?.append || false
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Fetch Salesforce CRM counts
|
||||||
|
export const fetchSalesforceCrmCounts = createAsyncThunk(
|
||||||
|
'salesforceCrm/fetchCounts',
|
||||||
|
async () => {
|
||||||
|
const response = await salesforceCrmAPI.getSalesforceCrmCounts();
|
||||||
|
return response.data || {
|
||||||
|
events: { count: 0, success: false },
|
||||||
|
leads: { count: 0, success: false },
|
||||||
|
tasks: { count: 0, success: false },
|
||||||
|
accounts: { count: 0, success: false },
|
||||||
|
opportunities: { count: 0, success: false },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Fetch all Salesforce CRM data
|
||||||
|
export const fetchAllSalesforceCrmData = createAsyncThunk(
|
||||||
|
'salesforceCrm/fetchAllData',
|
||||||
|
async (params?: SalesforceSearchParams) => {
|
||||||
|
const [
|
||||||
|
eventsResponse,
|
||||||
|
leadsResponse,
|
||||||
|
tasksResponse,
|
||||||
|
accountsResponse,
|
||||||
|
opportunitiesResponse
|
||||||
|
] = await Promise.all([
|
||||||
|
salesforceCrmAPI.getEvents(params),
|
||||||
|
salesforceCrmAPI.getLeads(params),
|
||||||
|
salesforceCrmAPI.getTasks(params),
|
||||||
|
salesforceCrmAPI.getAccounts(params),
|
||||||
|
salesforceCrmAPI.getOpportunities(params),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
events: {
|
||||||
|
data: eventsResponse.data?.data?.data || [],
|
||||||
|
count: eventsResponse.data?.data?.count || 0,
|
||||||
|
moreRecords: !eventsResponse.data?.data?.metadata?.done
|
||||||
|
},
|
||||||
|
leads: {
|
||||||
|
data: leadsResponse.data?.data?.data || [],
|
||||||
|
count: leadsResponse.data?.data?.count || 0,
|
||||||
|
moreRecords: !leadsResponse.data?.data?.metadata?.done
|
||||||
|
},
|
||||||
|
tasks: {
|
||||||
|
data: tasksResponse.data?.data?.data || [],
|
||||||
|
count: tasksResponse.data?.data?.count || 0,
|
||||||
|
moreRecords: !tasksResponse.data?.data?.metadata?.done
|
||||||
|
},
|
||||||
|
accounts: {
|
||||||
|
data: accountsResponse.data?.data?.data || [],
|
||||||
|
count: accountsResponse.data?.data?.count || 0,
|
||||||
|
moreRecords: !accountsResponse.data?.data?.metadata?.done
|
||||||
|
},
|
||||||
|
opportunities: {
|
||||||
|
data: opportunitiesResponse.data?.data?.data || [],
|
||||||
|
count: opportunitiesResponse.data?.data?.count || 0,
|
||||||
|
moreRecords: !opportunitiesResponse.data?.data?.metadata?.done
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Slice
|
||||||
|
const salesforceCrmSlice = createSlice({
|
||||||
|
name: 'salesforceCrm',
|
||||||
|
initialState,
|
||||||
|
reducers: {
|
||||||
|
clearErrors: (state) => {
|
||||||
|
state.errors = {
|
||||||
|
events: null,
|
||||||
|
leads: null,
|
||||||
|
tasks: null,
|
||||||
|
accounts: null,
|
||||||
|
opportunities: null,
|
||||||
|
stats: null,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
clearData: (state) => {
|
||||||
|
state.events = [];
|
||||||
|
state.leads = [];
|
||||||
|
state.tasks = [];
|
||||||
|
state.accounts = [];
|
||||||
|
state.opportunities = [];
|
||||||
|
state.stats = null;
|
||||||
|
},
|
||||||
|
// Reset pagination for specific data types
|
||||||
|
resetEventsPagination: (state) => {
|
||||||
|
state.pagination.events = { page: 1, count: 0, moreRecords: false };
|
||||||
|
state.events = [];
|
||||||
|
},
|
||||||
|
resetLeadsPagination: (state) => {
|
||||||
|
state.pagination.leads = { page: 1, count: 0, moreRecords: false };
|
||||||
|
state.leads = [];
|
||||||
|
},
|
||||||
|
resetTasksPagination: (state) => {
|
||||||
|
state.pagination.tasks = { page: 1, count: 0, moreRecords: false };
|
||||||
|
state.tasks = [];
|
||||||
|
},
|
||||||
|
resetAccountsPagination: (state) => {
|
||||||
|
state.pagination.accounts = { page: 1, count: 0, moreRecords: false };
|
||||||
|
state.accounts = [];
|
||||||
|
},
|
||||||
|
resetOpportunitiesPagination: (state) => {
|
||||||
|
state.pagination.opportunities = { page: 1, count: 0, moreRecords: false };
|
||||||
|
state.opportunities = [];
|
||||||
|
},
|
||||||
|
},
|
||||||
|
extraReducers: (builder) => {
|
||||||
|
// Fetch events
|
||||||
|
builder
|
||||||
|
.addCase(fetchSalesforceEvents.pending, (state) => {
|
||||||
|
state.loading.events = true;
|
||||||
|
state.errors.events = null;
|
||||||
|
})
|
||||||
|
.addCase(fetchSalesforceEvents.fulfilled, (state, action) => {
|
||||||
|
state.loading.events = false;
|
||||||
|
const { data, count, moreRecords, append } = action.payload;
|
||||||
|
|
||||||
|
if (append) {
|
||||||
|
// Append new data for infinite scrolling
|
||||||
|
state.events = [...state.events, ...data];
|
||||||
|
} else {
|
||||||
|
// Replace data for initial load or refresh
|
||||||
|
state.events = data;
|
||||||
|
}
|
||||||
|
|
||||||
|
state.pagination.events = {
|
||||||
|
page: append ? state.pagination.events.page + 1 : 1,
|
||||||
|
count,
|
||||||
|
moreRecords
|
||||||
|
};
|
||||||
|
state.lastUpdated.events = new Date().toISOString();
|
||||||
|
})
|
||||||
|
.addCase(fetchSalesforceEvents.rejected, (state, action) => {
|
||||||
|
state.loading.events = false;
|
||||||
|
state.errors.events = action.error.message || 'Failed to fetch events';
|
||||||
|
})
|
||||||
|
|
||||||
|
// Fetch leads
|
||||||
|
.addCase(fetchSalesforceLeads.pending, (state) => {
|
||||||
|
state.loading.leads = true;
|
||||||
|
state.errors.leads = null;
|
||||||
|
})
|
||||||
|
.addCase(fetchSalesforceLeads.fulfilled, (state, action) => {
|
||||||
|
state.loading.leads = false;
|
||||||
|
const { data, count, moreRecords, append } = action.payload;
|
||||||
|
|
||||||
|
if (append) {
|
||||||
|
state.leads = [...state.leads, ...data];
|
||||||
|
} else {
|
||||||
|
state.leads = data;
|
||||||
|
}
|
||||||
|
|
||||||
|
state.pagination.leads = {
|
||||||
|
page: append ? state.pagination.leads.page + 1 : 1,
|
||||||
|
count,
|
||||||
|
moreRecords
|
||||||
|
};
|
||||||
|
state.lastUpdated.leads = new Date().toISOString();
|
||||||
|
})
|
||||||
|
.addCase(fetchSalesforceLeads.rejected, (state, action) => {
|
||||||
|
state.loading.leads = false;
|
||||||
|
state.errors.leads = action.error.message || 'Failed to fetch leads';
|
||||||
|
})
|
||||||
|
|
||||||
|
// Fetch tasks
|
||||||
|
.addCase(fetchSalesforceTasks.pending, (state) => {
|
||||||
|
state.loading.tasks = true;
|
||||||
|
state.errors.tasks = null;
|
||||||
|
})
|
||||||
|
.addCase(fetchSalesforceTasks.fulfilled, (state, action) => {
|
||||||
|
state.loading.tasks = false;
|
||||||
|
const { data, count, moreRecords, append } = action.payload;
|
||||||
|
|
||||||
|
if (append) {
|
||||||
|
state.tasks = [...state.tasks, ...data];
|
||||||
|
} else {
|
||||||
|
state.tasks = data;
|
||||||
|
}
|
||||||
|
|
||||||
|
state.pagination.tasks = {
|
||||||
|
page: append ? state.pagination.tasks.page + 1 : 1,
|
||||||
|
count,
|
||||||
|
moreRecords
|
||||||
|
};
|
||||||
|
state.lastUpdated.tasks = new Date().toISOString();
|
||||||
|
})
|
||||||
|
.addCase(fetchSalesforceTasks.rejected, (state, action) => {
|
||||||
|
state.loading.tasks = false;
|
||||||
|
state.errors.tasks = action.error.message || 'Failed to fetch tasks';
|
||||||
|
})
|
||||||
|
|
||||||
|
// Fetch accounts
|
||||||
|
.addCase(fetchSalesforceAccounts.pending, (state) => {
|
||||||
|
state.loading.accounts = true;
|
||||||
|
state.errors.accounts = null;
|
||||||
|
})
|
||||||
|
.addCase(fetchSalesforceAccounts.fulfilled, (state, action) => {
|
||||||
|
state.loading.accounts = false;
|
||||||
|
const { data, count, moreRecords, append } = action.payload;
|
||||||
|
|
||||||
|
if (append) {
|
||||||
|
state.accounts = [...state.accounts, ...data];
|
||||||
|
} else {
|
||||||
|
state.accounts = data;
|
||||||
|
}
|
||||||
|
|
||||||
|
state.pagination.accounts = {
|
||||||
|
page: append ? state.pagination.accounts.page + 1 : 1,
|
||||||
|
count,
|
||||||
|
moreRecords
|
||||||
|
};
|
||||||
|
state.lastUpdated.accounts = new Date().toISOString();
|
||||||
|
})
|
||||||
|
.addCase(fetchSalesforceAccounts.rejected, (state, action) => {
|
||||||
|
state.loading.accounts = false;
|
||||||
|
state.errors.accounts = action.error.message || 'Failed to fetch accounts';
|
||||||
|
})
|
||||||
|
|
||||||
|
// Fetch opportunities
|
||||||
|
.addCase(fetchSalesforceOpportunities.pending, (state) => {
|
||||||
|
state.loading.opportunities = true;
|
||||||
|
state.errors.opportunities = null;
|
||||||
|
})
|
||||||
|
.addCase(fetchSalesforceOpportunities.fulfilled, (state, action) => {
|
||||||
|
state.loading.opportunities = false;
|
||||||
|
const { data, count, moreRecords, append } = action.payload;
|
||||||
|
|
||||||
|
if (append) {
|
||||||
|
state.opportunities = [...state.opportunities, ...data];
|
||||||
|
} else {
|
||||||
|
state.opportunities = data;
|
||||||
|
}
|
||||||
|
|
||||||
|
state.pagination.opportunities = {
|
||||||
|
page: append ? state.pagination.opportunities.page + 1 : 1,
|
||||||
|
count,
|
||||||
|
moreRecords
|
||||||
|
};
|
||||||
|
state.lastUpdated.opportunities = new Date().toISOString();
|
||||||
|
})
|
||||||
|
.addCase(fetchSalesforceOpportunities.rejected, (state, action) => {
|
||||||
|
state.loading.opportunities = false;
|
||||||
|
state.errors.opportunities = action.error.message || 'Failed to fetch opportunities';
|
||||||
|
})
|
||||||
|
|
||||||
|
// Fetch all Salesforce CRM data
|
||||||
|
.addCase(fetchAllSalesforceCrmData.pending, (state) => {
|
||||||
|
state.loading.events = true;
|
||||||
|
state.loading.leads = true;
|
||||||
|
state.loading.tasks = true;
|
||||||
|
state.loading.accounts = true;
|
||||||
|
state.loading.opportunities = true;
|
||||||
|
state.errors.events = null;
|
||||||
|
state.errors.leads = null;
|
||||||
|
state.errors.tasks = null;
|
||||||
|
state.errors.accounts = null;
|
||||||
|
state.errors.opportunities = null;
|
||||||
|
})
|
||||||
|
.addCase(fetchAllSalesforceCrmData.fulfilled, (state, action) => {
|
||||||
|
const { events, leads, tasks, accounts, opportunities } = action.payload;
|
||||||
|
|
||||||
|
state.loading.events = false;
|
||||||
|
state.loading.leads = false;
|
||||||
|
state.loading.tasks = false;
|
||||||
|
state.loading.accounts = false;
|
||||||
|
state.loading.opportunities = false;
|
||||||
|
|
||||||
|
state.events = events.data;
|
||||||
|
state.leads = leads.data;
|
||||||
|
state.tasks = tasks.data;
|
||||||
|
state.accounts = accounts.data;
|
||||||
|
state.opportunities = opportunities.data;
|
||||||
|
|
||||||
|
state.pagination.events = { page: 1, count: events.count, moreRecords: events.moreRecords };
|
||||||
|
state.pagination.leads = { page: 1, count: leads.count, moreRecords: leads.moreRecords };
|
||||||
|
state.pagination.tasks = { page: 1, count: tasks.count, moreRecords: tasks.moreRecords };
|
||||||
|
state.pagination.accounts = { page: 1, count: accounts.count, moreRecords: accounts.moreRecords };
|
||||||
|
state.pagination.opportunities = { page: 1, count: opportunities.count, moreRecords: opportunities.moreRecords };
|
||||||
|
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
state.lastUpdated.events = now;
|
||||||
|
state.lastUpdated.leads = now;
|
||||||
|
state.lastUpdated.tasks = now;
|
||||||
|
state.lastUpdated.accounts = now;
|
||||||
|
state.lastUpdated.opportunities = now;
|
||||||
|
})
|
||||||
|
.addCase(fetchAllSalesforceCrmData.rejected, (state, action) => {
|
||||||
|
state.loading.events = false;
|
||||||
|
state.loading.leads = false;
|
||||||
|
state.loading.tasks = false;
|
||||||
|
state.loading.accounts = false;
|
||||||
|
state.loading.opportunities = false;
|
||||||
|
|
||||||
|
const errorMessage = action.error.message || 'Failed to fetch Salesforce CRM data';
|
||||||
|
state.errors.events = errorMessage;
|
||||||
|
state.errors.leads = errorMessage;
|
||||||
|
state.errors.tasks = errorMessage;
|
||||||
|
state.errors.accounts = errorMessage;
|
||||||
|
state.errors.opportunities = errorMessage;
|
||||||
|
})
|
||||||
|
|
||||||
|
// Fetch Salesforce CRM counts
|
||||||
|
.addCase(fetchSalesforceCrmCounts.pending, (state) => {
|
||||||
|
// No loading state needed for counts as it's fetched in background
|
||||||
|
})
|
||||||
|
.addCase(fetchSalesforceCrmCounts.fulfilled, (state, action) => {
|
||||||
|
const data = action.payload as any;
|
||||||
|
state.counts = {
|
||||||
|
events: data.events?.count || 0,
|
||||||
|
leads: data.leads?.count || 0,
|
||||||
|
tasks: data.tasks?.count || 0,
|
||||||
|
accounts: data.accounts?.count || 0,
|
||||||
|
opportunities: data.opportunities?.count || 0,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.addCase(fetchSalesforceCrmCounts.rejected, (state) => {
|
||||||
|
// Keep existing counts on error
|
||||||
|
console.warn('Failed to fetch Salesforce CRM counts');
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const {
|
||||||
|
clearErrors,
|
||||||
|
clearData,
|
||||||
|
resetEventsPagination,
|
||||||
|
resetLeadsPagination,
|
||||||
|
resetTasksPagination,
|
||||||
|
resetAccountsPagination,
|
||||||
|
resetOpportunitiesPagination,
|
||||||
|
} = salesforceCrmSlice.actions;
|
||||||
|
|
||||||
|
export default salesforceCrmSlice.reducer;
|
||||||
|
|
||||||
150
src/modules/crm/salesforce/store/selectors.ts
Normal file
150
src/modules/crm/salesforce/store/selectors.ts
Normal file
@ -0,0 +1,150 @@
|
|||||||
|
import { createSelector } from '@reduxjs/toolkit';
|
||||||
|
import type { RootState } from '@/store/store';
|
||||||
|
import type { SalesforceCrmState } from './salesforceCrmSlice';
|
||||||
|
|
||||||
|
// Base selector
|
||||||
|
const selectSalesforceCrmState = (state: RootState): SalesforceCrmState =>
|
||||||
|
state.salesforceCrm as SalesforceCrmState;
|
||||||
|
|
||||||
|
// Data selectors
|
||||||
|
export const selectSalesforceEvents = createSelector(
|
||||||
|
[selectSalesforceCrmState],
|
||||||
|
(state) => state.events
|
||||||
|
);
|
||||||
|
|
||||||
|
export const selectSalesforceLeads = createSelector(
|
||||||
|
[selectSalesforceCrmState],
|
||||||
|
(state) => state.leads
|
||||||
|
);
|
||||||
|
|
||||||
|
export const selectSalesforceTasks = createSelector(
|
||||||
|
[selectSalesforceCrmState],
|
||||||
|
(state) => state.tasks
|
||||||
|
);
|
||||||
|
|
||||||
|
export const selectSalesforceAccounts = createSelector(
|
||||||
|
[selectSalesforceCrmState],
|
||||||
|
(state) => state.accounts
|
||||||
|
);
|
||||||
|
|
||||||
|
export const selectSalesforceOpportunities = createSelector(
|
||||||
|
[selectSalesforceCrmState],
|
||||||
|
(state) => state.opportunities
|
||||||
|
);
|
||||||
|
|
||||||
|
// Loading selectors
|
||||||
|
export const selectSalesforceCrmLoading = createSelector(
|
||||||
|
[selectSalesforceCrmState],
|
||||||
|
(state) => state.loading
|
||||||
|
);
|
||||||
|
|
||||||
|
export const selectIsAnyLoading = createSelector(
|
||||||
|
[selectSalesforceCrmLoading],
|
||||||
|
(loading) => Object.values(loading).some(isLoading => isLoading)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Error selectors
|
||||||
|
export const selectSalesforceCrmErrors = createSelector(
|
||||||
|
[selectSalesforceCrmState],
|
||||||
|
(state) => state.errors
|
||||||
|
);
|
||||||
|
|
||||||
|
export const selectHasAnyError = createSelector(
|
||||||
|
[selectSalesforceCrmErrors],
|
||||||
|
(errors) => Object.values(errors).some(error => error !== null)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Pagination selectors
|
||||||
|
export const selectEventsPagination = createSelector(
|
||||||
|
[selectSalesforceCrmState],
|
||||||
|
(state) => state.pagination.events
|
||||||
|
);
|
||||||
|
|
||||||
|
export const selectLeadsPagination = createSelector(
|
||||||
|
[selectSalesforceCrmState],
|
||||||
|
(state) => state.pagination.leads
|
||||||
|
);
|
||||||
|
|
||||||
|
export const selectTasksPagination = createSelector(
|
||||||
|
[selectSalesforceCrmState],
|
||||||
|
(state) => state.pagination.tasks
|
||||||
|
);
|
||||||
|
|
||||||
|
export const selectAccountsPagination = createSelector(
|
||||||
|
[selectSalesforceCrmState],
|
||||||
|
(state) => state.pagination.accounts
|
||||||
|
);
|
||||||
|
|
||||||
|
export const selectOpportunitiesPagination = createSelector(
|
||||||
|
[selectSalesforceCrmState],
|
||||||
|
(state) => state.pagination.opportunities
|
||||||
|
);
|
||||||
|
|
||||||
|
// Count selectors
|
||||||
|
export const selectSalesforceCrmCounts = createSelector(
|
||||||
|
[selectSalesforceCrmState],
|
||||||
|
(state) => state.counts
|
||||||
|
);
|
||||||
|
|
||||||
|
// Stats selectors
|
||||||
|
export const selectSalesforceCrmStats = createSelector(
|
||||||
|
[selectSalesforceCrmState],
|
||||||
|
(state) => state.stats
|
||||||
|
);
|
||||||
|
|
||||||
|
// Dashboard data selector - combines all data for dashboard view
|
||||||
|
export const selectDashboardData = createSelector(
|
||||||
|
[
|
||||||
|
selectSalesforceEvents,
|
||||||
|
selectSalesforceLeads,
|
||||||
|
selectSalesforceTasks,
|
||||||
|
selectSalesforceAccounts,
|
||||||
|
selectSalesforceOpportunities,
|
||||||
|
selectSalesforceCrmCounts,
|
||||||
|
],
|
||||||
|
(events, leads, tasks, accounts, opportunities, counts) => ({
|
||||||
|
events,
|
||||||
|
leads,
|
||||||
|
tasks,
|
||||||
|
accounts,
|
||||||
|
opportunities,
|
||||||
|
counts,
|
||||||
|
summary: {
|
||||||
|
totalEvents: events.length,
|
||||||
|
totalLeads: leads.length,
|
||||||
|
totalTasks: tasks.length,
|
||||||
|
totalAccounts: accounts.length,
|
||||||
|
totalOpportunities: opportunities.length,
|
||||||
|
|
||||||
|
// Event stats
|
||||||
|
upcomingEvents: events.filter(e => e.StartDateTime && new Date(e.StartDateTime) > new Date()).length,
|
||||||
|
|
||||||
|
// Lead stats
|
||||||
|
newLeads: leads.filter(l => l.Status === 'New').length,
|
||||||
|
workingLeads: leads.filter(l => l.Status === 'Working').length,
|
||||||
|
qualifiedLeads: leads.filter(l => l.Status === 'Qualified').length,
|
||||||
|
|
||||||
|
// Task stats
|
||||||
|
openTasks: tasks.filter(t => t.Status === 'Open').length,
|
||||||
|
completedTasks: tasks.filter(t => t.Status === 'Completed').length,
|
||||||
|
highPriorityTasks: tasks.filter(t => t.Priority === 'High').length,
|
||||||
|
|
||||||
|
// Opportunity stats
|
||||||
|
openOpportunities: opportunities.filter(o => !o.IsClosed).length,
|
||||||
|
totalPipelineValue: opportunities
|
||||||
|
.filter(o => !o.IsClosed)
|
||||||
|
.reduce((sum, o) => sum + (o.Amount || 0), 0),
|
||||||
|
averageDealSize: opportunities.length > 0
|
||||||
|
? opportunities.reduce((sum, o) => sum + (o.Amount || 0), 0) / opportunities.length
|
||||||
|
: 0,
|
||||||
|
wonOpportunities: opportunities.filter(o => o.IsWon).length,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// Last updated selector
|
||||||
|
export const selectLastUpdated = createSelector(
|
||||||
|
[selectSalesforceCrmState],
|
||||||
|
(state) => state.lastUpdated
|
||||||
|
);
|
||||||
|
|
||||||
273
src/modules/crm/salesforce/types/SalesforceCrmTypes.ts
Normal file
273
src/modules/crm/salesforce/types/SalesforceCrmTypes.ts
Normal file
@ -0,0 +1,273 @@
|
|||||||
|
// Salesforce CRM Data Types for Integration
|
||||||
|
|
||||||
|
export interface SalesforceEvent {
|
||||||
|
Id: string;
|
||||||
|
Subject: string;
|
||||||
|
StartDateTime: string | null;
|
||||||
|
EndDateTime: string | null;
|
||||||
|
Location: string;
|
||||||
|
Description: string | null;
|
||||||
|
OwnerId: string;
|
||||||
|
WhatId: string | null;
|
||||||
|
WhoId: string | null;
|
||||||
|
IsAllDayEvent: boolean;
|
||||||
|
CreatedDate: string;
|
||||||
|
attributes: {
|
||||||
|
type: string;
|
||||||
|
url: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SalesforceLead {
|
||||||
|
Id: string;
|
||||||
|
FirstName: string;
|
||||||
|
LastName: string;
|
||||||
|
Company: string;
|
||||||
|
Email: string;
|
||||||
|
Status: LeadStatus;
|
||||||
|
Phone?: string;
|
||||||
|
Title?: string;
|
||||||
|
Street?: string;
|
||||||
|
City?: string;
|
||||||
|
State?: string;
|
||||||
|
Country?: string;
|
||||||
|
PostalCode?: string;
|
||||||
|
Website?: string;
|
||||||
|
Industry?: string;
|
||||||
|
NumberOfEmployees?: number;
|
||||||
|
AnnualRevenue?: number;
|
||||||
|
OwnerId?: string;
|
||||||
|
LeadSource?: string;
|
||||||
|
Description?: string;
|
||||||
|
CreatedDate?: string;
|
||||||
|
LastModifiedDate?: string;
|
||||||
|
attributes: {
|
||||||
|
type: string;
|
||||||
|
url: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SalesforceTask {
|
||||||
|
Id: string;
|
||||||
|
Subject: string;
|
||||||
|
Status: TaskStatus;
|
||||||
|
Priority: TaskPriority;
|
||||||
|
ActivityDate: string | null;
|
||||||
|
WhatId: string | null;
|
||||||
|
WhoId: string | null;
|
||||||
|
OwnerId: string;
|
||||||
|
Description: string | null;
|
||||||
|
CreatedDate: string;
|
||||||
|
LastModifiedDate: string;
|
||||||
|
IsClosed?: boolean;
|
||||||
|
IsHighPriority?: boolean;
|
||||||
|
attributes: {
|
||||||
|
type: string;
|
||||||
|
url: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SalesforceAccount {
|
||||||
|
Id: string;
|
||||||
|
Name: string;
|
||||||
|
Industry: string | null;
|
||||||
|
Type: string | null;
|
||||||
|
Phone: string | null;
|
||||||
|
BillingCity: string | null;
|
||||||
|
BillingState: string | null;
|
||||||
|
BillingCountry: string | null;
|
||||||
|
BillingStreet?: string | null;
|
||||||
|
BillingPostalCode?: string | null;
|
||||||
|
ShippingCity?: string | null;
|
||||||
|
ShippingState?: string | null;
|
||||||
|
ShippingCountry?: string | null;
|
||||||
|
ShippingStreet?: string | null;
|
||||||
|
ShippingPostalCode?: string | null;
|
||||||
|
Website: string | null;
|
||||||
|
OwnerId: string;
|
||||||
|
CreatedDate: string;
|
||||||
|
LastModifiedDate?: string;
|
||||||
|
Description?: string | null;
|
||||||
|
NumberOfEmployees?: number;
|
||||||
|
AnnualRevenue?: number;
|
||||||
|
attributes: {
|
||||||
|
type: string;
|
||||||
|
url: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SalesforceOpportunity {
|
||||||
|
Id: string;
|
||||||
|
Name: string;
|
||||||
|
StageName: OpportunityStage;
|
||||||
|
Amount: number;
|
||||||
|
CloseDate: string;
|
||||||
|
AccountId: string | null;
|
||||||
|
Type: string | null;
|
||||||
|
Probability: number;
|
||||||
|
ForecastCategory: string;
|
||||||
|
OwnerId: string;
|
||||||
|
CreatedDate: string;
|
||||||
|
LastModifiedDate: string;
|
||||||
|
Description?: string | null;
|
||||||
|
LeadSource?: string | null;
|
||||||
|
NextStep?: string | null;
|
||||||
|
IsClosed?: boolean;
|
||||||
|
IsWon?: boolean;
|
||||||
|
ExpectedRevenue?: number;
|
||||||
|
attributes: {
|
||||||
|
type: string;
|
||||||
|
url: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enums and Union Types
|
||||||
|
export type LeadStatus = 'New' | 'Working' | 'Nurturing' | 'Qualified' | 'Unqualified';
|
||||||
|
export type TaskStatus = 'Open' | 'In Progress' | 'Completed' | 'Waiting' | 'Deferred';
|
||||||
|
export type TaskPriority = 'High' | 'Normal' | 'Low';
|
||||||
|
export type OpportunityStage =
|
||||||
|
| 'Prospecting'
|
||||||
|
| 'Qualification'
|
||||||
|
| 'Needs Analysis'
|
||||||
|
| 'Value Proposition'
|
||||||
|
| 'Identify Decision Makers'
|
||||||
|
| 'Perception Analysis'
|
||||||
|
| 'Proposal/Price Quote'
|
||||||
|
| 'Negotiation/Review'
|
||||||
|
| 'Closed Won'
|
||||||
|
| 'Closed Lost';
|
||||||
|
|
||||||
|
// Combined Salesforce CRM Data Interface
|
||||||
|
export interface SalesforceCrmData {
|
||||||
|
events: SalesforceEvent[];
|
||||||
|
leads: SalesforceLead[];
|
||||||
|
tasks: SalesforceTask[];
|
||||||
|
accounts: SalesforceAccount[];
|
||||||
|
opportunities: SalesforceOpportunity[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// API Response Types
|
||||||
|
export interface SalesforceApiResponse<T> {
|
||||||
|
status: 'success' | 'error';
|
||||||
|
message: string;
|
||||||
|
data?: {
|
||||||
|
success: boolean;
|
||||||
|
data: T[];
|
||||||
|
count: number;
|
||||||
|
metadata: {
|
||||||
|
totalSize: number;
|
||||||
|
done: boolean;
|
||||||
|
nextRecordsUrl: string | null;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
error?: string;
|
||||||
|
timestamp: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SalesforcePaginatedResponse<T> {
|
||||||
|
data: T[];
|
||||||
|
info: {
|
||||||
|
count: number;
|
||||||
|
moreRecords: boolean;
|
||||||
|
page: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter and Search Types
|
||||||
|
export interface SalesforceFilters {
|
||||||
|
events?: {
|
||||||
|
dateRange?: {
|
||||||
|
start: string;
|
||||||
|
end: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
leads?: {
|
||||||
|
status?: LeadStatus[];
|
||||||
|
source?: string[];
|
||||||
|
assignedTo?: string[];
|
||||||
|
dateRange?: {
|
||||||
|
start: string;
|
||||||
|
end: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
tasks?: {
|
||||||
|
status?: TaskStatus[];
|
||||||
|
priority?: TaskPriority[];
|
||||||
|
assignedTo?: string[];
|
||||||
|
dueDateRange?: {
|
||||||
|
start: string;
|
||||||
|
end: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
accounts?: {
|
||||||
|
industry?: string[];
|
||||||
|
type?: string[];
|
||||||
|
assignedTo?: string[];
|
||||||
|
};
|
||||||
|
opportunities?: {
|
||||||
|
stage?: OpportunityStage[];
|
||||||
|
owner?: string[];
|
||||||
|
amountRange?: {
|
||||||
|
min: number;
|
||||||
|
max: number;
|
||||||
|
};
|
||||||
|
closeDateRange?: {
|
||||||
|
start: string;
|
||||||
|
end: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SalesforceSearchParams {
|
||||||
|
query?: string;
|
||||||
|
type?: 'events' | 'leads' | 'tasks' | 'accounts' | 'opportunities' | 'all';
|
||||||
|
filters?: SalesforceFilters;
|
||||||
|
sortBy?: string;
|
||||||
|
sortOrder?: 'asc' | 'desc';
|
||||||
|
page?: number;
|
||||||
|
limit?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Statistics and Analytics Types
|
||||||
|
export interface SalesforceStats {
|
||||||
|
events: {
|
||||||
|
total: number;
|
||||||
|
upcoming: number;
|
||||||
|
completed: number;
|
||||||
|
byLocation: Record<string, number>;
|
||||||
|
};
|
||||||
|
leads: {
|
||||||
|
total: number;
|
||||||
|
new: number;
|
||||||
|
working: number;
|
||||||
|
qualified: number;
|
||||||
|
byStatus: Record<LeadStatus, number>;
|
||||||
|
bySource: Record<string, number>;
|
||||||
|
};
|
||||||
|
tasks: {
|
||||||
|
total: number;
|
||||||
|
open: number;
|
||||||
|
completed: number;
|
||||||
|
overdue: number;
|
||||||
|
byStatus: Record<TaskStatus, number>;
|
||||||
|
byPriority: Record<TaskPriority, number>;
|
||||||
|
};
|
||||||
|
accounts: {
|
||||||
|
total: number;
|
||||||
|
byIndustry: Record<string, number>;
|
||||||
|
byType: Record<string, number>;
|
||||||
|
totalRevenue: number;
|
||||||
|
};
|
||||||
|
opportunities: {
|
||||||
|
total: number;
|
||||||
|
open: number;
|
||||||
|
won: number;
|
||||||
|
lost: number;
|
||||||
|
totalValue: number;
|
||||||
|
averageDealSize: number;
|
||||||
|
byStage: Record<OpportunityStage, number>;
|
||||||
|
pipelineValue: number;
|
||||||
|
winRate: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
@ -8,6 +8,7 @@ import { useDispatch } from 'react-redux';
|
|||||||
import { setSelectedService } from '@/modules/integrations/store/integrationsSlice';
|
import { setSelectedService } from '@/modules/integrations/store/integrationsSlice';
|
||||||
import type { AppDispatch } from '@/store/store';
|
import type { AppDispatch } from '@/store/store';
|
||||||
import ZohoAuth from './ZohoAuth';
|
import ZohoAuth from './ZohoAuth';
|
||||||
|
import SalesforceAuth from './SalesforceAuth';
|
||||||
import { Modal } from 'react-native';
|
import { Modal } from 'react-native';
|
||||||
import httpClient from '@/services/http';
|
import httpClient from '@/services/http';
|
||||||
import { useNavigation } from '@react-navigation/native';
|
import { useNavigation } from '@react-navigation/native';
|
||||||
@ -61,6 +62,7 @@ const IntegrationCategoryScreen: React.FC<Props> = ({ route }) => {
|
|||||||
const dispatch = useDispatch<AppDispatch>();
|
const dispatch = useDispatch<AppDispatch>();
|
||||||
const navigation = useNavigation();
|
const navigation = useNavigation();
|
||||||
const [showZohoAuth, setShowZohoAuth] = React.useState(false);
|
const [showZohoAuth, setShowZohoAuth] = React.useState(false);
|
||||||
|
const [showSalesforceAuth, setShowSalesforceAuth] = React.useState(false);
|
||||||
const [pendingService, setPendingService] = React.useState<string | null>(null);
|
const [pendingService, setPendingService] = React.useState<string | null>(null);
|
||||||
const [isCheckingToken, setIsCheckingToken] = React.useState(false);
|
const [isCheckingToken, setIsCheckingToken] = React.useState(false);
|
||||||
const [authenticatedServices, setAuthenticatedServices] = React.useState<Set<string>>(new Set());
|
const [authenticatedServices, setAuthenticatedServices] = React.useState<Set<string>>(new Set());
|
||||||
@ -102,11 +104,50 @@ const IntegrationCategoryScreen: React.FC<Props> = ({ route }) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Check for existing Salesforce token
|
||||||
|
const checkSalesforceToken = async (serviceKey: string, forceReauth: boolean = false) => {
|
||||||
|
try {
|
||||||
|
setIsCheckingToken(true);
|
||||||
|
|
||||||
|
if (forceReauth) {
|
||||||
|
// Force re-authentication by showing auth modal
|
||||||
|
setPendingService(serviceKey);
|
||||||
|
setShowSalesforceAuth(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await httpClient.get('/api/v1/users/decrypt-token?service_name=salesforce');
|
||||||
|
const responseData = response.data as any;
|
||||||
|
|
||||||
|
if (responseData.status === 'success' && responseData.data?.accessToken) {
|
||||||
|
// Token exists and is valid, mark as authenticated
|
||||||
|
// CrmNavigator will automatically show SalesforceDashboard based on selectedService
|
||||||
|
console.log('Salesforce token found, setting selected service');
|
||||||
|
setAuthenticatedServices(prev => new Set([...prev, serviceKey]));
|
||||||
|
dispatch(setSelectedService(serviceKey));
|
||||||
|
} else {
|
||||||
|
// No valid token, show auth modal
|
||||||
|
setPendingService(serviceKey);
|
||||||
|
setShowSalesforceAuth(true);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.log('No valid Salesforce token found, showing auth modal');
|
||||||
|
// Token doesn't exist or is invalid, show auth modal
|
||||||
|
setPendingService(serviceKey);
|
||||||
|
setShowSalesforceAuth(true);
|
||||||
|
} finally {
|
||||||
|
setIsCheckingToken(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Handle re-authentication
|
// Handle re-authentication
|
||||||
const handleReAuthenticate = (serviceKey: string) => {
|
const handleReAuthenticate = (serviceKey: string) => {
|
||||||
|
const isSalesforce = serviceKey === 'salesforce';
|
||||||
|
const serviceTitle = isSalesforce ? 'Salesforce' : 'Zoho';
|
||||||
|
|
||||||
Alert.alert(
|
Alert.alert(
|
||||||
'Re-authenticate',
|
'Re-authenticate',
|
||||||
'This will allow you to change your organization or re-authorize access. Continue?',
|
`This will allow you to change your ${serviceTitle} organization or re-authorize access. Continue?`,
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
text: 'Cancel',
|
text: 'Cancel',
|
||||||
@ -114,7 +155,13 @@ const IntegrationCategoryScreen: React.FC<Props> = ({ route }) => {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
text: 'Re-authenticate',
|
text: 'Re-authenticate',
|
||||||
onPress: () => checkZohoToken(serviceKey, true),
|
onPress: () => {
|
||||||
|
if (isSalesforce) {
|
||||||
|
checkSalesforceToken(serviceKey, true);
|
||||||
|
} else {
|
||||||
|
checkZohoToken(serviceKey, true);
|
||||||
|
}
|
||||||
|
},
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
@ -129,6 +176,8 @@ const IntegrationCategoryScreen: React.FC<Props> = ({ route }) => {
|
|||||||
ItemSeparatorComponent={() => <View style={[styles.sep, { backgroundColor: colors.border }]} />}
|
ItemSeparatorComponent={() => <View style={[styles.sep, { backgroundColor: colors.border }]} />}
|
||||||
renderItem={({ item }) => {
|
renderItem={({ item }) => {
|
||||||
const requiresZohoAuth = item.key === 'zohoProjects' || item.key === 'zohoPeople' || item.key === 'zohoBooks' || item.key === 'zohoCRM';
|
const requiresZohoAuth = item.key === 'zohoProjects' || item.key === 'zohoPeople' || item.key === 'zohoBooks' || item.key === 'zohoCRM';
|
||||||
|
const requiresSalesforceAuth = item.key === 'salesforce';
|
||||||
|
const requiresAuth = requiresZohoAuth || requiresSalesforceAuth;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.serviceItem}>
|
<View style={styles.serviceItem}>
|
||||||
@ -142,8 +191,10 @@ const IntegrationCategoryScreen: React.FC<Props> = ({ route }) => {
|
|||||||
|
|
||||||
if (requiresZohoAuth) {
|
if (requiresZohoAuth) {
|
||||||
checkZohoToken(item.key);
|
checkZohoToken(item.key);
|
||||||
|
} else if (requiresSalesforceAuth) {
|
||||||
|
checkSalesforceToken(item.key);
|
||||||
} else {
|
} else {
|
||||||
// For non-Zoho services, navigate to Coming Soon screen
|
// For other services, navigate to Coming Soon screen
|
||||||
navigation.navigate('ComingSoon' as never);
|
navigation.navigate('ComingSoon' as never);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
@ -161,8 +212,8 @@ const IntegrationCategoryScreen: React.FC<Props> = ({ route }) => {
|
|||||||
)}
|
)}
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
|
|
||||||
{/* Re-authentication button for Zoho services - always visible */}
|
{/* Re-authentication button for services that require auth */}
|
||||||
{requiresZohoAuth && (
|
{requiresAuth && (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={[styles.reauthButton, { backgroundColor: colors.background, borderColor: colors.border }]}
|
style={[styles.reauthButton, { backgroundColor: colors.background, borderColor: colors.border }]}
|
||||||
onPress={() => handleReAuthenticate(item.key)}
|
onPress={() => handleReAuthenticate(item.key)}
|
||||||
@ -190,7 +241,7 @@ const IntegrationCategoryScreen: React.FC<Props> = ({ route }) => {
|
|||||||
<ZohoAuth
|
<ZohoAuth
|
||||||
serviceKey={pendingService as any}
|
serviceKey={pendingService as any}
|
||||||
onAuthSuccess={(authData) => {
|
onAuthSuccess={(authData) => {
|
||||||
console.log('auth data i got',authData)
|
console.log('Zoho auth data received:', authData);
|
||||||
setShowZohoAuth(false);
|
setShowZohoAuth(false);
|
||||||
if (pendingService) {
|
if (pendingService) {
|
||||||
// Mark service as authenticated
|
// Mark service as authenticated
|
||||||
@ -209,6 +260,37 @@ const IntegrationCategoryScreen: React.FC<Props> = ({ route }) => {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
|
{/* Salesforce Auth Modal */}
|
||||||
|
<Modal
|
||||||
|
visible={showSalesforceAuth}
|
||||||
|
animationType="slide"
|
||||||
|
presentationStyle="fullScreen"
|
||||||
|
onRequestClose={() => setShowSalesforceAuth(false)}
|
||||||
|
>
|
||||||
|
<SalesforceAuth
|
||||||
|
onAuthSuccess={(authData) => {
|
||||||
|
console.log('Salesforce auth data received:', authData);
|
||||||
|
setShowSalesforceAuth(false);
|
||||||
|
if (pendingService) {
|
||||||
|
// Mark service as authenticated
|
||||||
|
// CrmNavigator will automatically show SalesforceDashboard based on selectedService
|
||||||
|
setAuthenticatedServices(prev => new Set([...prev, pendingService]));
|
||||||
|
dispatch(setSelectedService(pendingService));
|
||||||
|
setPendingService(null);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onAuthError={(error) => {
|
||||||
|
console.error('Salesforce auth error:', error);
|
||||||
|
setShowSalesforceAuth(false);
|
||||||
|
setPendingService(null);
|
||||||
|
}}
|
||||||
|
onClose={() => {
|
||||||
|
setShowSalesforceAuth(false);
|
||||||
|
setPendingService(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Modal>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -0,0 +1,600 @@
|
|||||||
|
# Salesforce OAuth - Backend Callback Flow
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
This document explains the backend-handled OAuth callback flow for Salesforce authentication, where the backend receives the authorization code directly from Salesforce and handles the token exchange.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architecture Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────┐
|
||||||
|
│ Mobile App │
|
||||||
|
│ (SalesforceAuth)│
|
||||||
|
└────────┬────────┘
|
||||||
|
│ 1. User taps Salesforce
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────────────────────────────────────────────────────┐
|
||||||
|
│ Build OAuth URL with backend callback: │
|
||||||
|
│ https://login.salesforce.com/services/oauth2/authorize │
|
||||||
|
│ ?client_id=YOUR_CLIENT_ID │
|
||||||
|
│ &redirect_uri=YOUR_BACKEND_URL/callback │
|
||||||
|
│ ?user_uuid=USER_ID&service_name=salesforce │
|
||||||
|
│ &response_type=code │
|
||||||
|
│ &scope=api refresh_token offline_access... │
|
||||||
|
└────────────────┬────────────────────────────────────────┘
|
||||||
|
│ 2. Open in WebView
|
||||||
|
▼
|
||||||
|
┌────────────────┐
|
||||||
|
│ Salesforce │
|
||||||
|
│ Login Page │
|
||||||
|
└────────┬───────┘
|
||||||
|
│ 3. User authenticates
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌────────────────────────────────────────────┐
|
||||||
|
│ Salesforce redirects to backend callback: │
|
||||||
|
│ https://YOUR_BACKEND/callback │
|
||||||
|
│ ?user_uuid=USER_ID │
|
||||||
|
│ &service_name=salesforce │
|
||||||
|
│ &code=AUTHORIZATION_CODE │
|
||||||
|
└────────┬───────────────────────────────────┘
|
||||||
|
│ 4. Backend receives callback
|
||||||
|
▼
|
||||||
|
┌────────────────────────────────────────────┐
|
||||||
|
│ Your Backend Server │
|
||||||
|
│ │
|
||||||
|
│ • Extract: authorization_code, user_uuid, │
|
||||||
|
│ service_name from query params │
|
||||||
|
│ │
|
||||||
|
│ • Exchange code for tokens with Salesforce:│
|
||||||
|
│ POST /services/oauth2/token │
|
||||||
|
│ grant_type=authorization_code │
|
||||||
|
│ code=CODE │
|
||||||
|
│ client_id=YOUR_CLIENT_ID │
|
||||||
|
│ client_secret=YOUR_CLIENT_SECRET │
|
||||||
|
│ redirect_uri=YOUR_CALLBACK_URL │
|
||||||
|
│ │
|
||||||
|
│ • Store tokens (encrypted) in database │
|
||||||
|
│ associated with user_uuid │
|
||||||
|
│ │
|
||||||
|
│ • Return HTML page or redirect with │
|
||||||
|
│ status indicator │
|
||||||
|
└────────┬───────────────────────────────────┘
|
||||||
|
│ 5. Backend responds
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌────────────────────────────────────────────┐
|
||||||
|
│ Backend returns success/error page: │
|
||||||
|
│ │
|
||||||
|
│ Success: ?status=success │
|
||||||
|
│ OR ?success=true │
|
||||||
|
│ │
|
||||||
|
│ Error: ?status=error │
|
||||||
|
│ &message=Error+description │
|
||||||
|
└────────┬───────────────────────────────────┘
|
||||||
|
│ 6. WebView detects callback response
|
||||||
|
▼
|
||||||
|
┌────────────────┐
|
||||||
|
│ Mobile App │
|
||||||
|
│ • Detect backend callback URL │
|
||||||
|
│ • Check for status=success or error │
|
||||||
|
│ • Show success/error to user │
|
||||||
|
│ • Close auth modal │
|
||||||
|
└────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Backend Implementation
|
||||||
|
|
||||||
|
### Endpoint: `GET /api/v1/users/oauth/callback`
|
||||||
|
|
||||||
|
#### Query Parameters (Received from Salesforce)
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
user_uuid: string; // User ID passed in redirect_uri
|
||||||
|
service_name: string; // 'salesforce' passed in redirect_uri
|
||||||
|
code: string; // Authorization code from Salesforce
|
||||||
|
state?: string; // Optional CSRF token
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Implementation Steps
|
||||||
|
|
||||||
|
##### 1. Extract Query Parameters
|
||||||
|
```javascript
|
||||||
|
const { code: authorization_code, user_uuid, service_name } = req.query;
|
||||||
|
|
||||||
|
// Validate required parameters
|
||||||
|
if (!authorization_code || !user_uuid || !service_name) {
|
||||||
|
return res.redirect(`/oauth/error?message=Missing required parameters`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify service_name is 'salesforce'
|
||||||
|
if (service_name !== 'salesforce') {
|
||||||
|
return res.redirect(`/oauth/error?message=Invalid service name`);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
##### 2. Exchange Authorization Code for Tokens
|
||||||
|
```javascript
|
||||||
|
const axios = require('axios');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const tokenResponse = await axios.post(
|
||||||
|
'https://login.salesforce.com/services/oauth2/token',
|
||||||
|
null,
|
||||||
|
{
|
||||||
|
params: {
|
||||||
|
grant_type: 'authorization_code',
|
||||||
|
code: authorization_code,
|
||||||
|
client_id: process.env.SALESFORCE_CLIENT_ID,
|
||||||
|
client_secret: process.env.SALESFORCE_CLIENT_SECRET,
|
||||||
|
redirect_uri: `${process.env.BACKEND_URL}/api/v1/users/oauth/callback?user_uuid=${user_uuid}&service_name=salesforce`
|
||||||
|
},
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const {
|
||||||
|
access_token,
|
||||||
|
refresh_token,
|
||||||
|
instance_url,
|
||||||
|
id,
|
||||||
|
issued_at,
|
||||||
|
signature
|
||||||
|
} = tokenResponse.data;
|
||||||
|
|
||||||
|
// Store tokens...
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Token exchange failed:', error);
|
||||||
|
return res.redirect(`/oauth/error?message=${encodeURIComponent(error.message)}`);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
##### 3. Store Tokens Securely
|
||||||
|
```javascript
|
||||||
|
// Encrypt tokens before storing
|
||||||
|
const encryptedAccessToken = encrypt(access_token);
|
||||||
|
const encryptedRefreshToken = encrypt(refresh_token);
|
||||||
|
|
||||||
|
await db.integrations.upsert({
|
||||||
|
where: {
|
||||||
|
user_id: user_uuid,
|
||||||
|
service_name: 'salesforce'
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
access_token: encryptedAccessToken,
|
||||||
|
refresh_token: encryptedRefreshToken,
|
||||||
|
instance_url: instance_url,
|
||||||
|
token_issued_at: new Date(parseInt(issued_at)),
|
||||||
|
updated_at: new Date()
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
user_id: user_uuid,
|
||||||
|
service_name: 'salesforce',
|
||||||
|
access_token: encryptedAccessToken,
|
||||||
|
refresh_token: encryptedRefreshToken,
|
||||||
|
instance_url: instance_url,
|
||||||
|
token_issued_at: new Date(parseInt(issued_at)),
|
||||||
|
created_at: new Date()
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
##### 4. Return Success Response
|
||||||
|
```javascript
|
||||||
|
// Option 1: Redirect to success page with status
|
||||||
|
res.redirect('/oauth/success?status=success&service=salesforce');
|
||||||
|
|
||||||
|
// Option 2: Return HTML page that mobile app can detect
|
||||||
|
res.send(`
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>Authentication Successful</title>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Arial, sans-serif;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
height: 100vh;
|
||||||
|
margin: 0;
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
}
|
||||||
|
.container {
|
||||||
|
text-align: center;
|
||||||
|
background: white;
|
||||||
|
padding: 40px;
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 10px 40px rgba(0,0,0,0.2);
|
||||||
|
}
|
||||||
|
.success-icon {
|
||||||
|
font-size: 64px;
|
||||||
|
color: #10b981;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
color: #1f2937;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
p {
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<div class="success-icon">✓</div>
|
||||||
|
<h1>Authentication Successful!</h1>
|
||||||
|
<p>Your Salesforce account has been connected.</p>
|
||||||
|
<p>You can close this window now.</p>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
// Mobile app will detect this URL and close the WebView
|
||||||
|
window.location.search = '?status=success';
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`);
|
||||||
|
```
|
||||||
|
|
||||||
|
##### 5. Handle Errors
|
||||||
|
```javascript
|
||||||
|
// If token exchange or storage fails
|
||||||
|
res.send(`
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>Authentication Failed</title>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Arial, sans-serif;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
height: 100vh;
|
||||||
|
margin: 0;
|
||||||
|
background: linear-gradient(135deg, #f87171 0%, #ef4444 100%);
|
||||||
|
}
|
||||||
|
.container {
|
||||||
|
text-align: center;
|
||||||
|
background: white;
|
||||||
|
padding: 40px;
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 10px 40px rgba(0,0,0,0.2);
|
||||||
|
}
|
||||||
|
.error-icon {
|
||||||
|
font-size: 64px;
|
||||||
|
color: #ef4444;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
color: #1f2937;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
p {
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<div class="error-icon">✗</div>
|
||||||
|
<h1>Authentication Failed</h1>
|
||||||
|
<p>${errorMessage || 'An error occurred during authentication.'}</p>
|
||||||
|
<p>Please try again.</p>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
window.location.search = '?status=error&message=${encodeURIComponent(errorMessage)}';
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Mobile App Detection Logic
|
||||||
|
|
||||||
|
The mobile app (SalesforceAuth component) detects the backend callback using these helper functions:
|
||||||
|
|
||||||
|
### Detection Functions
|
||||||
|
```typescript
|
||||||
|
// Check if URL is the backend callback URI
|
||||||
|
const isBackendCallbackUri = (url: string): boolean => {
|
||||||
|
return url.includes('/api/v1/users/oauth/callback');
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check if backend callback indicates success
|
||||||
|
const isCallbackSuccess = (url: string): boolean => {
|
||||||
|
const status = getQueryParamFromUrl(url, 'status');
|
||||||
|
const success = getQueryParamFromUrl(url, 'success');
|
||||||
|
return status === 'success' || success === 'true';
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check if backend callback indicates error
|
||||||
|
const isCallbackError = (url: string): boolean => {
|
||||||
|
const status = getQueryParamFromUrl(url, 'status');
|
||||||
|
const error = getQueryParamFromUrl(url, 'error');
|
||||||
|
return status === 'error' || status === 'failure' || !!error;
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### Navigation State Handler
|
||||||
|
```typescript
|
||||||
|
handleNavigationStateChange = (navState) => {
|
||||||
|
const { url, loading } = navState;
|
||||||
|
|
||||||
|
// Check if this is the backend callback URL
|
||||||
|
if (isBackendCallbackUri(url)) {
|
||||||
|
console.log('Backend callback detected');
|
||||||
|
|
||||||
|
// Show processing modal
|
||||||
|
setState({ processing: true, processingStep: 'Processing...' });
|
||||||
|
|
||||||
|
// Check for success
|
||||||
|
if (isCallbackSuccess(url)) {
|
||||||
|
handleBackendSuccess();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for error
|
||||||
|
if (isCallbackError(url)) {
|
||||||
|
const errorMessage = getQueryParamFromUrl(url, 'message') || 'Authentication failed';
|
||||||
|
handleBackendError(errorMessage);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Complete Backend Example (Node.js/Express)
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const express = require('express');
|
||||||
|
const axios = require('axios');
|
||||||
|
const crypto = require('crypto');
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
// Encryption utilities
|
||||||
|
function encrypt(text) {
|
||||||
|
const algorithm = 'aes-256-gcm';
|
||||||
|
const key = Buffer.from(process.env.ENCRYPTION_KEY, 'hex');
|
||||||
|
const iv = crypto.randomBytes(16);
|
||||||
|
|
||||||
|
const cipher = crypto.createCipheriv(algorithm, key, iv);
|
||||||
|
let encrypted = cipher.update(text, 'utf8', 'hex');
|
||||||
|
encrypted += cipher.final('hex');
|
||||||
|
|
||||||
|
const authTag = cipher.getAuthTag();
|
||||||
|
|
||||||
|
return `${iv.toString('hex')}:${authTag.toString('hex')}:${encrypted}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// OAuth Callback Endpoint
|
||||||
|
router.get('/api/v1/users/oauth/callback', async (req, res) => {
|
||||||
|
const { code: authorization_code, user_uuid, service_name } = req.query;
|
||||||
|
|
||||||
|
console.log('[OAuth Callback] Received:', { user_uuid, service_name, hasCode: !!authorization_code });
|
||||||
|
|
||||||
|
// Validate required parameters
|
||||||
|
if (!authorization_code || !user_uuid || !service_name) {
|
||||||
|
return res.send(errorPage('Missing required parameters'));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (service_name !== 'salesforce') {
|
||||||
|
return res.send(errorPage('Invalid service name'));
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Exchange authorization code for tokens
|
||||||
|
const tokenResponse = await axios.post(
|
||||||
|
'https://login.salesforce.com/services/oauth2/token',
|
||||||
|
null,
|
||||||
|
{
|
||||||
|
params: {
|
||||||
|
grant_type: 'authorization_code',
|
||||||
|
code: authorization_code,
|
||||||
|
client_id: process.env.SALESFORCE_CLIENT_ID,
|
||||||
|
client_secret: process.env.SALESFORCE_CLIENT_SECRET,
|
||||||
|
redirect_uri: `${process.env.BACKEND_URL}/api/v1/users/oauth/callback?user_uuid=${user_uuid}&service_name=salesforce`
|
||||||
|
},
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const {
|
||||||
|
access_token,
|
||||||
|
refresh_token,
|
||||||
|
instance_url,
|
||||||
|
id,
|
||||||
|
issued_at
|
||||||
|
} = tokenResponse.data;
|
||||||
|
|
||||||
|
console.log('[OAuth Callback] Token exchange successful');
|
||||||
|
|
||||||
|
// Encrypt and store tokens
|
||||||
|
const encryptedAccessToken = encrypt(access_token);
|
||||||
|
const encryptedRefreshToken = encrypt(refresh_token);
|
||||||
|
|
||||||
|
await db.integrations.upsert({
|
||||||
|
where: {
|
||||||
|
user_id: user_uuid,
|
||||||
|
service_name: 'salesforce'
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
access_token: encryptedAccessToken,
|
||||||
|
refresh_token: encryptedRefreshToken,
|
||||||
|
instance_url: instance_url,
|
||||||
|
token_issued_at: new Date(parseInt(issued_at)),
|
||||||
|
updated_at: new Date()
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
user_id: user_uuid,
|
||||||
|
service_name: 'salesforce',
|
||||||
|
access_token: encryptedAccessToken,
|
||||||
|
refresh_token: encryptedRefreshToken,
|
||||||
|
instance_url: instance_url,
|
||||||
|
token_issued_at: new Date(parseInt(issued_at)),
|
||||||
|
created_at: new Date()
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('[OAuth Callback] Tokens stored successfully');
|
||||||
|
|
||||||
|
// Return success page
|
||||||
|
return res.send(successPage());
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[OAuth Callback] Error:', error);
|
||||||
|
return res.send(errorPage(error.message));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Success page HTML
|
||||||
|
function successPage() {
|
||||||
|
return `
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>Success</title>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<style>
|
||||||
|
body { font-family: Arial, sans-serif; text-align: center; padding: 50px; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); }
|
||||||
|
.container { background: white; padding: 40px; border-radius: 12px; box-shadow: 0 10px 40px rgba(0,0,0,0.2); display: inline-block; }
|
||||||
|
.icon { font-size: 64px; color: #10b981; margin-bottom: 20px; }
|
||||||
|
h1 { color: #1f2937; }
|
||||||
|
p { color: #6b7280; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<div class="icon">✓</div>
|
||||||
|
<h1>Authentication Successful!</h1>
|
||||||
|
<p>Your Salesforce account has been connected.</p>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
setTimeout(() => { window.location.search = '?status=success'; }, 500);
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error page HTML
|
||||||
|
function errorPage(message) {
|
||||||
|
return `
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>Error</title>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<style>
|
||||||
|
body { font-family: Arial, sans-serif; text-align: center; padding: 50px; background: linear-gradient(135deg, #f87171 0%, #ef4444 100%); }
|
||||||
|
.container { background: white; padding: 40px; border-radius: 12px; box-shadow: 0 10px 40px rgba(0,0,0,0.2); display: inline-block; }
|
||||||
|
.icon { font-size: 64px; color: #ef4444; margin-bottom: 20px; }
|
||||||
|
h1 { color: #1f2937; }
|
||||||
|
p { color: #6b7280; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<div class="icon">✗</div>
|
||||||
|
<h1>Authentication Failed</h1>
|
||||||
|
<p>${message || 'An error occurred.'}</p>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
setTimeout(() => { window.location.search = '?status=error&message=${encodeURIComponent(message)}'; }, 500);
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Configuration Checklist
|
||||||
|
|
||||||
|
### Salesforce Connected App
|
||||||
|
- ✅ Callback URL: `https://YOUR_BACKEND_URL/api/v1/users/oauth/callback`
|
||||||
|
- ✅ OAuth Scopes: api, refresh_token, offline_access, profile, email, address, phone
|
||||||
|
|
||||||
|
### Mobile App (SalesforceAuth.tsx)
|
||||||
|
- ✅ `BACKEND_BASE_URL`: Your backend domain
|
||||||
|
- ✅ `CALLBACK_PATH`: `/api/v1/users/oauth/callback`
|
||||||
|
- ✅ `CLIENT_ID`: Salesforce Consumer Key
|
||||||
|
|
||||||
|
### Backend Environment Variables
|
||||||
|
- ✅ `SALESFORCE_CLIENT_ID`: Consumer Key from Salesforce
|
||||||
|
- ✅ `SALESFORCE_CLIENT_SECRET`: Consumer Secret from Salesforce
|
||||||
|
- ✅ `BACKEND_URL`: Your public backend URL
|
||||||
|
- ✅ `ENCRYPTION_KEY`: 32-byte hex string for token encryption
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
1. **Test OAuth Flow**:
|
||||||
|
- Open app → CRM & Sales → Salesforce
|
||||||
|
- Complete Salesforce login
|
||||||
|
- Verify redirect to backend callback
|
||||||
|
- Check backend logs for token exchange
|
||||||
|
- Verify success page appears
|
||||||
|
- Confirm modal closes automatically
|
||||||
|
|
||||||
|
2. **Test Error Handling**:
|
||||||
|
- Simulate invalid credentials
|
||||||
|
- Simulate network failure
|
||||||
|
- Verify error page displays
|
||||||
|
- Confirm retry functionality works
|
||||||
|
|
||||||
|
3. **Verify Token Storage**:
|
||||||
|
- Check database for encrypted tokens
|
||||||
|
- Verify user_id association
|
||||||
|
- Confirm refresh_token is stored
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Security Considerations
|
||||||
|
|
||||||
|
1. **HTTPS Only**: Always use HTTPS for callback URLs
|
||||||
|
2. **Token Encryption**: Encrypt tokens before storing in database
|
||||||
|
3. **Client Secret**: Never expose client secret to mobile app
|
||||||
|
4. **State Parameter**: Add CSRF protection using state parameter
|
||||||
|
5. **Token Expiration**: Implement token refresh logic
|
||||||
|
6. **Access Control**: Validate user_uuid belongs to authenticated session
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
| Issue | Solution |
|
||||||
|
|-------|----------|
|
||||||
|
| Callback URL mismatch | Ensure Salesforce Connected App callback URL exactly matches backend URL with query params |
|
||||||
|
| Token exchange fails | Check client_id and client_secret are correct |
|
||||||
|
| Authorization code expired | Codes expire in 15 minutes - ensure timely exchange |
|
||||||
|
| Backend not reachable | Verify backend URL is publicly accessible (use ngrok for local testing) |
|
||||||
|
| WebView doesn't detect success | Ensure backend returns HTML with `?status=success` in URL |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Last Updated**: October 2025
|
||||||
|
**Version**: 2.0.0 (Backend Callback Flow)
|
||||||
|
|
||||||
452
src/modules/integrations/screens/SALESFORCE_SETUP.md
Normal file
452
src/modules/integrations/screens/SALESFORCE_SETUP.md
Normal file
@ -0,0 +1,452 @@
|
|||||||
|
# Salesforce OAuth Integration Setup Guide
|
||||||
|
|
||||||
|
This guide will help you configure Salesforce OAuth authentication for your Centralized Reporting System application.
|
||||||
|
|
||||||
|
## Table of Contents
|
||||||
|
1. [Prerequisites](#prerequisites)
|
||||||
|
2. [Create a Salesforce Connected App](#create-a-salesforce-connected-app)
|
||||||
|
3. [Configure OAuth Settings](#configure-oauth-settings)
|
||||||
|
4. [Update Application Configuration](#update-application-configuration)
|
||||||
|
5. [Test the Integration](#test-the-integration)
|
||||||
|
6. [Troubleshooting](#troubleshooting)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
Before you begin, ensure you have:
|
||||||
|
- A Salesforce account (Production or Sandbox)
|
||||||
|
- Administrator access to Salesforce
|
||||||
|
- Your backend API ready to handle OAuth token exchange
|
||||||
|
- React Native app with WebView capability
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Create a Salesforce Connected App
|
||||||
|
|
||||||
|
### Step 1: Access Setup
|
||||||
|
1. Log in to your Salesforce account
|
||||||
|
2. Click the **gear icon** (⚙️) in the top right corner
|
||||||
|
3. Select **Setup** from the dropdown menu
|
||||||
|
|
||||||
|
### Step 2: Navigate to App Manager
|
||||||
|
1. In the Quick Find box, type **"App Manager"**
|
||||||
|
2. Click **App Manager** under Platform Tools > Apps
|
||||||
|
3. Click **New Connected App** button
|
||||||
|
|
||||||
|
### Step 3: Basic Information
|
||||||
|
Fill in the following required fields:
|
||||||
|
- **Connected App Name**: `Centralized Reporting System` (or your preferred name)
|
||||||
|
- **API Name**: `Centralized_Reporting_System` (auto-populated)
|
||||||
|
- **Contact Email**: Your email address
|
||||||
|
|
||||||
|
### Step 4: API (Enable OAuth Settings)
|
||||||
|
1. Check the box **"Enable OAuth Settings"**
|
||||||
|
2. **Callback URL**: Enter your redirect URI
|
||||||
|
```
|
||||||
|
centralizedreportingsystem://oauth/salesforce/callback
|
||||||
|
```
|
||||||
|
> Note: You can add multiple callback URLs for different environments (development, staging, production)
|
||||||
|
|
||||||
|
3. **Selected OAuth Scopes**: Select the following scopes and move them to "Selected OAuth Scopes":
|
||||||
|
- Access the identity URL service (id, profile, email, address, phone)
|
||||||
|
- Access unique user identifiers (openid)
|
||||||
|
- Perform requests at any time (refresh_token, offline_access)
|
||||||
|
- Manage user data via APIs (api)
|
||||||
|
- Full access to all data (full) - Optional, use with caution
|
||||||
|
|
||||||
|
4. **Additional OAuth Settings**:
|
||||||
|
- ✅ **Enable for Device Flow**: Check this if you want to support device authentication
|
||||||
|
- ✅ **Require Secret for Web Server Flow**: Recommended for security
|
||||||
|
- ✅ **Require Secret for Refresh Token Flow**: Recommended for security
|
||||||
|
- ✅ **Enable Authorization Code and Credentials Flow**: Check this
|
||||||
|
|
||||||
|
5. Click **Save** button
|
||||||
|
|
||||||
|
### Step 5: Wait for Salesforce Processing
|
||||||
|
After saving, Salesforce will process your app (usually 2-10 minutes).
|
||||||
|
|
||||||
|
### Step 6: Retrieve OAuth Credentials
|
||||||
|
1. Once processing is complete, navigate back to **App Manager**
|
||||||
|
2. Find your Connected App and click the dropdown arrow (▼)
|
||||||
|
3. Select **View**
|
||||||
|
4. In the "API (Enable OAuth Settings)" section, you'll see:
|
||||||
|
- **Consumer Key** (Client ID)
|
||||||
|
- **Consumer Secret** (Client Secret) - Click "Click to reveal"
|
||||||
|
|
||||||
|
> ⚠️ **Important**: Keep these credentials secure! Never commit them to public repositories.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Configure OAuth Settings
|
||||||
|
|
||||||
|
### Environment-Specific Endpoints
|
||||||
|
|
||||||
|
#### Production
|
||||||
|
```
|
||||||
|
Auth Base URL: https://login.salesforce.com
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Sandbox
|
||||||
|
```
|
||||||
|
Auth Base URL: https://test.salesforce.com
|
||||||
|
```
|
||||||
|
|
||||||
|
### OAuth Scopes Explained
|
||||||
|
|
||||||
|
| Scope | Description |
|
||||||
|
|-------|-------------|
|
||||||
|
| `api` | Access to all standard Salesforce REST APIs |
|
||||||
|
| `refresh_token` | Ability to refresh the access token |
|
||||||
|
| `offline_access` | Perform requests even when the user is offline |
|
||||||
|
| `id` | Access to the unique identifier for the user |
|
||||||
|
| `profile` | Access to user's profile information |
|
||||||
|
| `email` | Access to user's email address |
|
||||||
|
| `address` | Access to user's address |
|
||||||
|
| `phone` | Access to user's phone number |
|
||||||
|
| `openid` | OpenID Connect authentication |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Update Application Configuration
|
||||||
|
|
||||||
|
### Step 1: Update SalesforceAuth.tsx
|
||||||
|
|
||||||
|
Open `src/modules/integrations/screens/SalesforceAuth.tsx` and update the following:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const SALESFORCE_CONFIG = {
|
||||||
|
// Replace with your Consumer Key (Client ID) from Salesforce
|
||||||
|
CLIENT_ID: '3MVG9XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX',
|
||||||
|
|
||||||
|
// This must match exactly what you configured in Salesforce Connected App
|
||||||
|
REDIRECT_URI: 'centralizedreportingsystem://oauth/salesforce/callback',
|
||||||
|
|
||||||
|
// Use login.salesforce.com for production
|
||||||
|
// Use test.salesforce.com for sandbox
|
||||||
|
AUTH_BASE_URL: 'https://login.salesforce.com',
|
||||||
|
|
||||||
|
RESPONSE_TYPE: 'code',
|
||||||
|
DISPLAY: 'touch', // Optimized for mobile
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 2: Configure Deep Linking (Android)
|
||||||
|
|
||||||
|
#### Android - Update AndroidManifest.xml
|
||||||
|
Add the intent filter to handle the OAuth callback:
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<!-- android/app/src/main/AndroidManifest.xml -->
|
||||||
|
<activity
|
||||||
|
android:name=".MainActivity"
|
||||||
|
android:label="@string/app_name"
|
||||||
|
android:launchMode="singleTask">
|
||||||
|
|
||||||
|
<!-- Existing intent filters -->
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
|
</intent-filter>
|
||||||
|
|
||||||
|
<!-- Salesforce OAuth Callback -->
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.VIEW" />
|
||||||
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
<category android:name="android.intent.category.BROWSABLE" />
|
||||||
|
<data
|
||||||
|
android:scheme="centralizedreportingsystem"
|
||||||
|
android:host="oauth"
|
||||||
|
android:pathPrefix="/salesforce/callback" />
|
||||||
|
</intent-filter>
|
||||||
|
</activity>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 3: Configure Deep Linking (iOS)
|
||||||
|
|
||||||
|
#### iOS - Update Info.plist
|
||||||
|
Add URL types for the OAuth callback:
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<!-- ios/CentralizedReportingSystem/Info.plist -->
|
||||||
|
<key>CFBundleURLTypes</key>
|
||||||
|
<array>
|
||||||
|
<dict>
|
||||||
|
<key>CFBundleURLSchemes</key>
|
||||||
|
<array>
|
||||||
|
<string>centralizedreportingsystem</string>
|
||||||
|
</array>
|
||||||
|
<key>CFBundleURLName</key>
|
||||||
|
<string>com.yourcompany.centralizedreportingsystem</string>
|
||||||
|
</dict>
|
||||||
|
</array>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 4: Backend Token Exchange Endpoint
|
||||||
|
|
||||||
|
Your backend should have an endpoint to exchange the authorization code for an access token:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Example backend endpoint
|
||||||
|
POST /api/v1/integrations/manage-token
|
||||||
|
|
||||||
|
Request Body:
|
||||||
|
{
|
||||||
|
"authorization_code": "aPrxXXXXXXXXXXXXXXXXXXXXXXXXX",
|
||||||
|
"id": "user-uuid",
|
||||||
|
"service_name": "salesforce",
|
||||||
|
"access_token": "user-session-token"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The backend should:
|
||||||
|
1. Receive the authorization code
|
||||||
|
2. Exchange it with Salesforce for an access token
|
||||||
|
3. Store the tokens securely (encrypted)
|
||||||
|
4. Return success/failure response
|
||||||
|
|
||||||
|
#### Salesforce Token Exchange Request
|
||||||
|
|
||||||
|
Your backend should make this request to Salesforce:
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST https://login.salesforce.com/services/oauth2/token
|
||||||
|
Content-Type: application/x-www-form-urlencoded
|
||||||
|
|
||||||
|
grant_type=authorization_code
|
||||||
|
&code={AUTHORIZATION_CODE}
|
||||||
|
&client_id={YOUR_CLIENT_ID}
|
||||||
|
&client_secret={YOUR_CLIENT_SECRET}
|
||||||
|
&redirect_uri={YOUR_REDIRECT_URI}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Expected Salesforce Response
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"access_token": "00D...ABC",
|
||||||
|
"refresh_token": "5Aep...XYZ",
|
||||||
|
"signature": "signature-value",
|
||||||
|
"scope": "api refresh_token offline_access id profile email",
|
||||||
|
"id_token": "eyJ...",
|
||||||
|
"instance_url": "https://yourinstance.salesforce.com",
|
||||||
|
"id": "https://login.salesforce.com/id/00D.../005...",
|
||||||
|
"token_type": "Bearer",
|
||||||
|
"issued_at": "1699999999999"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Test the Integration
|
||||||
|
|
||||||
|
### Testing Steps
|
||||||
|
|
||||||
|
1. **Launch the App**
|
||||||
|
- Open your React Native application
|
||||||
|
- Navigate to **Integrations** > **CRM & Sales Integration**
|
||||||
|
|
||||||
|
2. **Select Salesforce**
|
||||||
|
- Tap on the **Salesforce** option
|
||||||
|
- The Salesforce authentication WebView should open
|
||||||
|
|
||||||
|
3. **Authenticate**
|
||||||
|
- Enter your Salesforce username and password
|
||||||
|
- If using Two-Factor Authentication, complete the verification
|
||||||
|
- Review and approve the OAuth permissions
|
||||||
|
|
||||||
|
4. **Verify Success**
|
||||||
|
- The app should capture the authorization code
|
||||||
|
- Backend should exchange it for tokens
|
||||||
|
- You should see a success message
|
||||||
|
- The auth modal should close automatically
|
||||||
|
|
||||||
|
5. **Check Data Sync** (if implemented)
|
||||||
|
- Navigate to the Salesforce dashboard
|
||||||
|
- Verify that data is being synced properly
|
||||||
|
|
||||||
|
### Testing with Different Salesforce Orgs
|
||||||
|
|
||||||
|
To test with different Salesforce organizations:
|
||||||
|
1. Tap the **Re-auth** button next to Salesforce
|
||||||
|
2. Confirm the re-authentication
|
||||||
|
3. Log in with a different Salesforce account
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Common Issues and Solutions
|
||||||
|
|
||||||
|
#### 1. **"redirect_uri_mismatch" Error**
|
||||||
|
|
||||||
|
**Problem**: The redirect URI doesn't match what's configured in Salesforce.
|
||||||
|
|
||||||
|
**Solution**:
|
||||||
|
- Ensure the redirect URI in `SalesforceAuth.tsx` exactly matches what's in the Salesforce Connected App
|
||||||
|
- Check for trailing slashes, capitalization, and special characters
|
||||||
|
- Both values must be identical: `centralizedreportingsystem://oauth/salesforce/callback`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### 2. **"invalid_client_id" Error**
|
||||||
|
|
||||||
|
**Problem**: The Client ID is incorrect or the Connected App is not yet active.
|
||||||
|
|
||||||
|
**Solution**:
|
||||||
|
- Wait 2-10 minutes after creating the Connected App for Salesforce to process it
|
||||||
|
- Double-check the Consumer Key (Client ID) from Salesforce matches your config
|
||||||
|
- Ensure there are no extra spaces or characters
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### 3. **WebView Doesn't Load**
|
||||||
|
|
||||||
|
**Problem**: The Salesforce login page doesn't appear.
|
||||||
|
|
||||||
|
**Solution**:
|
||||||
|
- Check your internet connection
|
||||||
|
- Verify the `AUTH_BASE_URL` is correct (login.salesforce.com or test.salesforce.com)
|
||||||
|
- Check console logs for error messages
|
||||||
|
- Ensure WebView has internet permission (Android)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### 4. **App Doesn't Capture the Code**
|
||||||
|
|
||||||
|
**Problem**: After authentication, the app doesn't proceed.
|
||||||
|
|
||||||
|
**Solution**:
|
||||||
|
- Verify deep linking is configured correctly for both iOS and Android
|
||||||
|
- Check that the redirect URI scheme matches your app's URL scheme
|
||||||
|
- Test the deep link manually using ADB (Android) or xcrun (iOS)
|
||||||
|
- Check console logs for navigation state changes
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### 5. **"access_denied" Error**
|
||||||
|
|
||||||
|
**Problem**: User denied the OAuth permissions.
|
||||||
|
|
||||||
|
**Solution**:
|
||||||
|
- User must approve all requested permissions
|
||||||
|
- If permissions are too broad, consider reducing the requested scopes
|
||||||
|
- Check if the Connected App is enabled for the user's profile
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### 6. **Token Exchange Fails**
|
||||||
|
|
||||||
|
**Problem**: Backend returns an error when exchanging the authorization code.
|
||||||
|
|
||||||
|
**Solution**:
|
||||||
|
- Verify backend endpoint is working: `/api/v1/integrations/manage-token`
|
||||||
|
- Check that the backend has the correct Consumer Secret
|
||||||
|
- Authorization codes expire after 15 minutes - ensure timely exchange
|
||||||
|
- Check backend logs for detailed error messages
|
||||||
|
- Verify the authorization code is being sent correctly
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### 7. **Sandbox vs Production Confusion**
|
||||||
|
|
||||||
|
**Problem**: Using the wrong Salesforce environment.
|
||||||
|
|
||||||
|
**Solution**:
|
||||||
|
- **Production**: Use `https://login.salesforce.com`
|
||||||
|
- **Sandbox**: Use `https://test.salesforce.com`
|
||||||
|
- Create separate Connected Apps for each environment
|
||||||
|
- Update the config based on your current environment
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Debug Logging
|
||||||
|
|
||||||
|
Enable comprehensive logging to troubleshoot issues:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// In SalesforceAuth.tsx, check console logs for:
|
||||||
|
console.log('[SalesforceAuth] Authorization code received:', authCode);
|
||||||
|
console.log('[SalesforceAuth] Response from manageToken:', response);
|
||||||
|
console.log('[SalesforceAuth] nav change url:', url, 'loading:', loading);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Security Best Practices
|
||||||
|
|
||||||
|
1. **Never Store Credentials in Code**
|
||||||
|
- Use environment variables or secure config management
|
||||||
|
- Never commit `.env` files with credentials to version control
|
||||||
|
|
||||||
|
2. **Use HTTPS Only**
|
||||||
|
- Always use `https://` for Salesforce endpoints
|
||||||
|
- Never use `http://` in production
|
||||||
|
|
||||||
|
3. **Implement Token Refresh**
|
||||||
|
- Store refresh tokens securely
|
||||||
|
- Implement automatic token refresh on expiration
|
||||||
|
- Handle refresh token failures gracefully
|
||||||
|
|
||||||
|
4. **Encrypt Stored Tokens**
|
||||||
|
- Use encryption for tokens stored on device
|
||||||
|
- Use secure storage mechanisms (Keychain, KeyStore)
|
||||||
|
|
||||||
|
5. **Validate State Parameter**
|
||||||
|
- Include CSRF protection using state parameter
|
||||||
|
- Verify state on callback to prevent CSRF attacks
|
||||||
|
|
||||||
|
6. **Limit OAuth Scopes**
|
||||||
|
- Request only the minimum scopes needed
|
||||||
|
- Avoid using `full` scope unless absolutely necessary
|
||||||
|
|
||||||
|
7. **Monitor Token Usage**
|
||||||
|
- Log token usage and refresh patterns
|
||||||
|
- Set up alerts for unusual authentication patterns
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Additional Resources
|
||||||
|
|
||||||
|
### Salesforce Documentation
|
||||||
|
- [Connected Apps Overview](https://help.salesforce.com/s/articleView?id=sf.connected_app_overview.htm)
|
||||||
|
- [OAuth 2.0 Authorization Flow](https://help.salesforce.com/s/articleView?id=sf.remoteaccess_oauth_flow.htm)
|
||||||
|
- [OAuth Scopes](https://help.salesforce.com/s/articleView?id=sf.remoteaccess_oauth_tokens_scopes.htm)
|
||||||
|
|
||||||
|
### React Native Resources
|
||||||
|
- [React Native WebView](https://github.com/react-native-webview/react-native-webview)
|
||||||
|
- [Deep Linking in React Native](https://reactnative.dev/docs/linking)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Support
|
||||||
|
|
||||||
|
If you encounter issues not covered in this guide:
|
||||||
|
|
||||||
|
1. Check Salesforce Setup Audit Trail for any configuration errors
|
||||||
|
2. Review React Native console logs
|
||||||
|
3. Check backend API logs for token exchange errors
|
||||||
|
4. Verify network requests in the debugger
|
||||||
|
5. Test with both Production and Sandbox environments
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Changelog
|
||||||
|
|
||||||
|
### Version 1.0.0
|
||||||
|
- Initial Salesforce OAuth integration
|
||||||
|
- Support for mobile OAuth flow
|
||||||
|
- Backend token exchange implementation
|
||||||
|
- Data sync scheduling
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
This integration follows your application's license terms.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Last Updated**: October 2025
|
||||||
|
**Maintained By**: Your Development Team
|
||||||
|
|
||||||
689
src/modules/integrations/screens/SalesforceAuth.tsx
Normal file
689
src/modules/integrations/screens/SalesforceAuth.tsx
Normal file
@ -0,0 +1,689 @@
|
|||||||
|
import React, { useState, useRef, useCallback, useMemo } from 'react';
|
||||||
|
import {
|
||||||
|
View,
|
||||||
|
Text,
|
||||||
|
StyleSheet,
|
||||||
|
Alert,
|
||||||
|
ActivityIndicator,
|
||||||
|
TouchableOpacity,
|
||||||
|
SafeAreaView,
|
||||||
|
} from 'react-native';
|
||||||
|
import { WebView } from 'react-native-webview';
|
||||||
|
import Icon from 'react-native-vector-icons/MaterialIcons';
|
||||||
|
import { useTheme } from '@/shared/styles/useTheme';
|
||||||
|
import { useNavigation } from '@react-navigation/native';
|
||||||
|
import { manageToken } from '../services/integrationAPI';
|
||||||
|
import { useSelector } from 'react-redux';
|
||||||
|
import { RootState } from '@/store/store';
|
||||||
|
import http from '@/services/http';
|
||||||
|
|
||||||
|
// Types
|
||||||
|
interface SalesforceAuthProps {
|
||||||
|
onAuthSuccess?: (authData: SalesforceAuthData) => void;
|
||||||
|
onAuthError?: (error: string) => void;
|
||||||
|
onClose?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SalesforceAuthData {
|
||||||
|
accessToken: string;
|
||||||
|
refreshToken?: string;
|
||||||
|
instanceUrl?: string;
|
||||||
|
id?: string;
|
||||||
|
issuedAt?: string;
|
||||||
|
signature?: string;
|
||||||
|
tokenType?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SalesforceAuthState {
|
||||||
|
loading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
currentUrl: string;
|
||||||
|
processing: boolean;
|
||||||
|
processingStep: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Salesforce OAuth Configuration
|
||||||
|
const SALESFORCE_CONFIG = {
|
||||||
|
// Replace with your actual Salesforce Connected App credentials
|
||||||
|
CLIENT_ID: '3MVG9GBhY6wQjl2sueQtv2NXMm3EuWtEvOQoeKRAzYcgs2.AWhkCPFitVFPYyUkiLRRdIww2fpr48_Inokd3F',
|
||||||
|
// Backend callback URL that will handle the OAuth callback
|
||||||
|
BACKEND_BASE_URL: 'https://512acb53a4a4.ngrok-free.app',
|
||||||
|
CALLBACK_PATH: '/api/v1/users/oauth/callback',
|
||||||
|
// Use login.salesforce.com for production, test.salesforce.com for sandbox
|
||||||
|
AUTH_BASE_URL: 'https://test.salesforce.com',
|
||||||
|
RESPONSE_TYPE: 'code',
|
||||||
|
DISPLAY: 'touch', // Optimized for mobile
|
||||||
|
};
|
||||||
|
|
||||||
|
// Salesforce OAuth scopes - request access to various Salesforce APIs
|
||||||
|
const SALESFORCE_SCOPES = [
|
||||||
|
'api', // Access to REST API
|
||||||
|
'refresh_token', // Ability to use refresh token
|
||||||
|
'offline_access', // Perform requests at any time
|
||||||
|
// 'id', // Access to unique identifier
|
||||||
|
'profile', // Access to profile information
|
||||||
|
'email', // Access to email address
|
||||||
|
'address', // Access to address information
|
||||||
|
'phone', // Access to phone number
|
||||||
|
// 'openid', // OpenID Connect
|
||||||
|
].join(' ');
|
||||||
|
|
||||||
|
// Build Salesforce OAuth URL with user-specific callback
|
||||||
|
const buildSalesforceAuthUrl = (userUuid: string): string => {
|
||||||
|
const baseUrl = `${SALESFORCE_CONFIG.AUTH_BASE_URL}/services/oauth2/authorize`;
|
||||||
|
|
||||||
|
// Build the redirect URI with query parameters that backend expects
|
||||||
|
const redirectUri = `${SALESFORCE_CONFIG.BACKEND_BASE_URL}${SALESFORCE_CONFIG.CALLBACK_PATH}?user_uuid=${encodeURIComponent(userUuid)}&service_name=salesforce`;
|
||||||
|
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
client_id: SALESFORCE_CONFIG.CLIENT_ID,
|
||||||
|
redirect_uri: redirectUri,
|
||||||
|
response_type: SALESFORCE_CONFIG.RESPONSE_TYPE,
|
||||||
|
display: SALESFORCE_CONFIG.DISPLAY,
|
||||||
|
scope: SALESFORCE_SCOPES,
|
||||||
|
// Add state parameter for security (CSRF protection)
|
||||||
|
state: Math.random().toString(36).substring(7),
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('[SalesforceAuth] Built OAuth URL:', `${baseUrl}?${params.toString()}`);
|
||||||
|
console.log('[SalesforceAuth] Redirect URI:', redirectUri);
|
||||||
|
return `${baseUrl}?${params.toString()}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Safe query param parser for React Native
|
||||||
|
const getQueryParamFromUrl = (url: string, key: string): string | null => {
|
||||||
|
try {
|
||||||
|
const queryIndex = url.indexOf('?');
|
||||||
|
if (queryIndex === -1) return null;
|
||||||
|
const query = url.substring(queryIndex + 1);
|
||||||
|
const pairs = query.split('&');
|
||||||
|
for (let i = 0; i < pairs.length; i += 1) {
|
||||||
|
const [rawK, rawV] = pairs[i].split('=');
|
||||||
|
const k = decodeURIComponent(rawK || '');
|
||||||
|
if (k === key) {
|
||||||
|
return decodeURIComponent(rawV || '');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
} catch (e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Parse all query params from URL into a plain object
|
||||||
|
const getAllQueryParamsFromUrl = (url: string): Record<string, any> => {
|
||||||
|
const result: Record<string, any> = {};
|
||||||
|
try {
|
||||||
|
const queryIndex = url.indexOf('?');
|
||||||
|
if (queryIndex === -1) return result;
|
||||||
|
const query = url.substring(queryIndex + 1);
|
||||||
|
const pairs = query.split('&');
|
||||||
|
for (let i = 0; i < pairs.length; i += 1) {
|
||||||
|
const [rawK, rawV] = pairs[i].split('=');
|
||||||
|
const k = decodeURIComponent(rawK || '');
|
||||||
|
const v = decodeURIComponent(rawV || '');
|
||||||
|
if (!k) continue;
|
||||||
|
// Coerce numeric values when appropriate
|
||||||
|
const numeric = /^-?\d+$/.test(v) ? Number(v) : v;
|
||||||
|
result[k] = numeric;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// swallow errors, return best-effort result
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Extract authorization code from URL
|
||||||
|
const extractAuthCode = (url: string): string | null => {
|
||||||
|
return getQueryParamFromUrl(url, 'code');
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check if URL is the backend callback URI
|
||||||
|
const isBackendCallbackUri = (url: string): boolean => {
|
||||||
|
return url.includes(SALESFORCE_CONFIG.CALLBACK_PATH);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check if backend callback indicates success
|
||||||
|
const isCallbackSuccess = (url: string): boolean => {
|
||||||
|
const status = getQueryParamFromUrl(url, 'status');
|
||||||
|
const success = getQueryParamFromUrl(url, 'success');
|
||||||
|
return status === 'success' || success === 'true';
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check if backend callback indicates error
|
||||||
|
const isCallbackError = (url: string): boolean => {
|
||||||
|
const status = getQueryParamFromUrl(url, 'status');
|
||||||
|
const error = getQueryParamFromUrl(url, 'error');
|
||||||
|
return status === 'error' || status === 'failure' || !!error;
|
||||||
|
};
|
||||||
|
|
||||||
|
const SalesforceAuth: React.FC<SalesforceAuthProps> = ({
|
||||||
|
onAuthSuccess,
|
||||||
|
onAuthError,
|
||||||
|
onClose,
|
||||||
|
}) => {
|
||||||
|
const { colors, spacing, fonts, shadows } = useTheme();
|
||||||
|
const webViewRef = useRef<WebView>(null);
|
||||||
|
const navigation = useNavigation<any>();
|
||||||
|
const { user, accessToken } = useSelector((state: RootState) => state.auth);
|
||||||
|
|
||||||
|
const [state, setState] = useState<SalesforceAuthState>({
|
||||||
|
loading: true,
|
||||||
|
error: null,
|
||||||
|
currentUrl: buildSalesforceAuthUrl(user?.uuid || ''),
|
||||||
|
processing: false,
|
||||||
|
processingStep: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle authorization code from callback URL
|
||||||
|
const handleAuthorizationCode = useCallback(async (authCode: string) => {
|
||||||
|
console.log('[SalesforceAuth] Authorization code captured:', authCode);
|
||||||
|
console.log('[SalesforceAuth] Manually hitting callback URL with query params');
|
||||||
|
|
||||||
|
// Show processing modal
|
||||||
|
setState(prev => ({
|
||||||
|
...prev,
|
||||||
|
processing: true,
|
||||||
|
processingStep: 'Connecting to Salesforce and storing credentials...'
|
||||||
|
}));
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Manually hit the callback URL endpoint with query parameters
|
||||||
|
// This mimics what Salesforce would have sent directly to the backend
|
||||||
|
const response = await http.get('/api/v1/users/oauth/callback', {
|
||||||
|
authorization_code: authCode,
|
||||||
|
user_uuid: user?.uuid || '',
|
||||||
|
service_name: 'salesforce'
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('[SalesforceAuth] Response from callback endpoint:', response);
|
||||||
|
|
||||||
|
// Check if response status is success
|
||||||
|
if (response?.data && typeof response.data === 'object' && 'status' in response.data && response.data.status === 'success') {
|
||||||
|
console.log('[SalesforceAuth] Token exchange successful');
|
||||||
|
|
||||||
|
// Update processing step
|
||||||
|
setState(prev => ({
|
||||||
|
...prev,
|
||||||
|
processingStep: 'Salesforce authentication successful!'
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Wait a moment to show success message
|
||||||
|
await new Promise<void>(resolve => setTimeout(resolve, 1500));
|
||||||
|
|
||||||
|
// Hide processing modal and call success callback
|
||||||
|
setState(prev => ({ ...prev, processing: false, processingStep: '' }));
|
||||||
|
|
||||||
|
// Call success callback
|
||||||
|
onAuthSuccess?.({
|
||||||
|
accessToken: authCode,
|
||||||
|
tokenType: 'authorization_code',
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const errorMessage = response?.data && typeof response.data === 'object' && 'message' in response.data
|
||||||
|
? String(response.data.message)
|
||||||
|
: 'Failed to authenticate with Salesforce';
|
||||||
|
console.warn('[SalesforceAuth] Token exchange failed:', errorMessage);
|
||||||
|
throw new Error(errorMessage);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[SalesforceAuth] Error during token exchange:', error);
|
||||||
|
|
||||||
|
// Hide processing modal
|
||||||
|
setState(prev => ({ ...prev, processing: false, processingStep: '' }));
|
||||||
|
|
||||||
|
// Call error callback
|
||||||
|
const errorMessage = error instanceof Error ? error.message : 'Authentication failed';
|
||||||
|
onAuthError?.(errorMessage);
|
||||||
|
|
||||||
|
// Show error alert
|
||||||
|
Alert.alert(
|
||||||
|
'Authentication Error',
|
||||||
|
errorMessage,
|
||||||
|
[
|
||||||
|
{
|
||||||
|
text: 'Retry',
|
||||||
|
onPress: () => {
|
||||||
|
setState(prev => ({
|
||||||
|
...prev,
|
||||||
|
error: null,
|
||||||
|
currentUrl: buildSalesforceAuthUrl(user?.uuid || '')
|
||||||
|
}));
|
||||||
|
webViewRef.current?.reload();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: 'Cancel',
|
||||||
|
onPress: onClose,
|
||||||
|
style: 'cancel',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}, [onAuthSuccess, onAuthError, onClose, user?.uuid]);
|
||||||
|
|
||||||
|
// Handle backend callback error
|
||||||
|
const handleBackendError = useCallback((url: string) => {
|
||||||
|
const errorMessage = getQueryParamFromUrl(url, 'message') ||
|
||||||
|
getQueryParamFromUrl(url, 'error_description') ||
|
||||||
|
'Failed to authenticate with Salesforce';
|
||||||
|
|
||||||
|
console.error('[SalesforceAuth] Backend callback error:', errorMessage);
|
||||||
|
|
||||||
|
// Hide processing modal
|
||||||
|
setState(prev => ({
|
||||||
|
...prev,
|
||||||
|
processing: false,
|
||||||
|
processingStep: '',
|
||||||
|
error: errorMessage
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Call error callback
|
||||||
|
onAuthError?.(errorMessage);
|
||||||
|
|
||||||
|
// Show error alert
|
||||||
|
Alert.alert(
|
||||||
|
'Authentication Error',
|
||||||
|
errorMessage,
|
||||||
|
[
|
||||||
|
{
|
||||||
|
text: 'Retry',
|
||||||
|
onPress: () => {
|
||||||
|
setState(prev => ({
|
||||||
|
...prev,
|
||||||
|
error: null,
|
||||||
|
currentUrl: buildSalesforceAuthUrl(user?.uuid || '')
|
||||||
|
}));
|
||||||
|
webViewRef.current?.reload();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: 'Cancel',
|
||||||
|
onPress: onClose,
|
||||||
|
style: 'cancel',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}, [onAuthError, onClose, user?.uuid]);
|
||||||
|
|
||||||
|
// Handle WebView navigation state changes
|
||||||
|
const handleNavigationStateChange = useCallback((navState: any) => {
|
||||||
|
const { url, loading } = navState;
|
||||||
|
console.log('[SalesforceAuth] nav change url:', url, 'loading:', loading);
|
||||||
|
|
||||||
|
setState(prev => ({
|
||||||
|
...prev,
|
||||||
|
loading,
|
||||||
|
currentUrl: url,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Check if this is the backend callback URL (after Salesforce redirects)
|
||||||
|
if (isBackendCallbackUri(url)) {
|
||||||
|
console.log('[SalesforceAuth] Backend callback detected');
|
||||||
|
|
||||||
|
// Extract authorization code from the URL
|
||||||
|
const authCode = extractAuthCode(url);
|
||||||
|
console.log('[SalesforceAuth] Authorization code in URL:', authCode);
|
||||||
|
|
||||||
|
// If we have an authorization code, capture it and send to backend
|
||||||
|
if (authCode && !state.processing) {
|
||||||
|
console.log('[SalesforceAuth] Capturing authorization code and sending to backend');
|
||||||
|
handleAuthorizationCode(authCode);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for success or error status (if backend already processed and redirected)
|
||||||
|
if (isCallbackSuccess(url)) {
|
||||||
|
console.log('[SalesforceAuth] Backend callback indicates success (already processed)');
|
||||||
|
// Just close the modal - backend already handled everything
|
||||||
|
setState(prev => ({ ...prev, processing: false }));
|
||||||
|
onAuthSuccess?.({
|
||||||
|
accessToken: 'token_exchanged_by_backend',
|
||||||
|
tokenType: 'Bearer',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isCallbackError(url)) {
|
||||||
|
console.log('[SalesforceAuth] Backend callback indicates error');
|
||||||
|
handleBackendError(url);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no code and no status, show processing
|
||||||
|
if (!state.processing) {
|
||||||
|
setState(prev => ({
|
||||||
|
...prev,
|
||||||
|
processing: true,
|
||||||
|
processingStep: 'Processing Salesforce authentication...'
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle Salesforce OAuth errors (before redirect to backend)
|
||||||
|
const oauthError = getQueryParamFromUrl(url, 'error');
|
||||||
|
if (oauthError) {
|
||||||
|
const errorDescription = getQueryParamFromUrl(url, 'error_description') || oauthError;
|
||||||
|
console.error('[SalesforceAuth] OAuth error:', errorDescription);
|
||||||
|
handleAuthError(errorDescription);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}, [state.processing, handleAuthorizationCode, handleBackendError, onAuthSuccess]);
|
||||||
|
|
||||||
|
// Allow WebView to load all URLs (backend callback is HTTP, not custom scheme)
|
||||||
|
const handleShouldStartLoadWithRequest = useCallback((request: any) => {
|
||||||
|
const { url } = request || {};
|
||||||
|
if (!url) return true;
|
||||||
|
|
||||||
|
// Log for debugging
|
||||||
|
console.log('[SalesforceAuth] onShouldStart load:', url);
|
||||||
|
|
||||||
|
// Allow all HTTP/HTTPS URLs (backend callback will be handled in handleNavigationStateChange)
|
||||||
|
return true;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Handle authentication error
|
||||||
|
const handleAuthError = useCallback((error: string) => {
|
||||||
|
setState(prev => ({ ...prev, error, loading: false }));
|
||||||
|
onAuthError?.(error);
|
||||||
|
|
||||||
|
Alert.alert(
|
||||||
|
'Salesforce Authentication Error',
|
||||||
|
error,
|
||||||
|
[
|
||||||
|
{
|
||||||
|
text: 'Retry',
|
||||||
|
onPress: () => {
|
||||||
|
setState(prev => ({
|
||||||
|
...prev,
|
||||||
|
error: null,
|
||||||
|
currentUrl: buildSalesforceAuthUrl(user?.uuid || '')
|
||||||
|
}));
|
||||||
|
webViewRef.current?.reload();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: 'Cancel',
|
||||||
|
onPress: onClose,
|
||||||
|
style: 'cancel',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}, [onAuthError, onClose, user?.uuid]);
|
||||||
|
|
||||||
|
// Handle WebView error
|
||||||
|
const handleWebViewError = useCallback((syntheticEvent: any) => {
|
||||||
|
const { nativeEvent } = syntheticEvent;
|
||||||
|
console.error('WebView error:', nativeEvent);
|
||||||
|
handleAuthError('Failed to load Salesforce login page. Please check your internet connection.');
|
||||||
|
}, [handleAuthError]);
|
||||||
|
|
||||||
|
const handleLoadStart = useCallback((e: any) => {
|
||||||
|
console.log('[SalesforceAuth] load start:', e?.nativeEvent?.url);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleLoadEnd = useCallback((e: any) => {
|
||||||
|
console.log('[SalesforceAuth] load end:', e?.nativeEvent?.url);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Handle close button press
|
||||||
|
const handleClose = useCallback(() => {
|
||||||
|
onClose?.();
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
|
// Handle reload
|
||||||
|
const handleReload = useCallback(() => {
|
||||||
|
setState(prev => ({
|
||||||
|
...prev,
|
||||||
|
error: null,
|
||||||
|
currentUrl: buildSalesforceAuthUrl(user?.uuid || '')
|
||||||
|
}));
|
||||||
|
webViewRef.current?.reload();
|
||||||
|
}, [user?.uuid]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SafeAreaView style={[styles.container, { backgroundColor: colors.background }]}>
|
||||||
|
{/* Header */}
|
||||||
|
<View style={[styles.header, { backgroundColor: colors.surface, ...shadows.light }]}>
|
||||||
|
<View style={styles.headerContent}>
|
||||||
|
<Icon name="cloud" size={24} color="#00A1E0" />
|
||||||
|
<Text style={[styles.headerTitle, { color: colors.text, fontFamily: fonts.medium }]}>
|
||||||
|
Salesforce Authentication
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.closeButton, { backgroundColor: colors.background }]}
|
||||||
|
onPress={handleClose}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
>
|
||||||
|
<Icon name="close" size={20} color={colors.text} />
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Error State */}
|
||||||
|
{state.error && (
|
||||||
|
<View style={[styles.errorContainer, { backgroundColor: colors.surface }]}>
|
||||||
|
<Icon name="error-outline" size={48} color={colors.error} />
|
||||||
|
<Text style={[styles.errorTitle, { color: colors.text, fontFamily: fonts.medium }]}>
|
||||||
|
Authentication Failed
|
||||||
|
</Text>
|
||||||
|
<Text style={[styles.errorMessage, { color: colors.textLight, fontFamily: fonts.regular }]}>
|
||||||
|
{state.error}
|
||||||
|
</Text>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.retryButton, { backgroundColor: '#00A1E0' }]}
|
||||||
|
onPress={handleReload}
|
||||||
|
activeOpacity={0.8}
|
||||||
|
>
|
||||||
|
<Text style={[styles.retryButtonText, { color: colors.surface, fontFamily: fonts.medium }]}>
|
||||||
|
Try Again
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Processing Modal */}
|
||||||
|
{state.processing && (
|
||||||
|
<View style={[styles.processingOverlay, { backgroundColor: 'rgba(0, 0, 0, 0.7)' }]}>
|
||||||
|
<View style={[styles.processingModal, { backgroundColor: colors.surface }]}>
|
||||||
|
<View style={styles.processingIconContainer}>
|
||||||
|
<ActivityIndicator size="large" color="#00A1E0" />
|
||||||
|
</View>
|
||||||
|
<Text style={[styles.processingTitle, { color: colors.text, fontFamily: fonts.bold }]}>
|
||||||
|
Setting Up Salesforce
|
||||||
|
</Text>
|
||||||
|
<Text style={[styles.processingStep, { color: colors.textLight, fontFamily: fonts.regular }]}>
|
||||||
|
{state.processingStep}
|
||||||
|
</Text>
|
||||||
|
<View style={styles.processingDots}>
|
||||||
|
<View style={[styles.dot, { backgroundColor: '#00A1E0' }]} />
|
||||||
|
<View style={[styles.dot, { backgroundColor: '#00A1E0' }]} />
|
||||||
|
<View style={[styles.dot, { backgroundColor: '#00A1E0' }]} />
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Loading Overlay */}
|
||||||
|
{state.loading && !state.error && (
|
||||||
|
<View style={[styles.loadingOverlay, { backgroundColor: colors.background }]}>
|
||||||
|
<ActivityIndicator size="large" color="#00A1E0" />
|
||||||
|
<Text style={[styles.loadingText, { color: colors.textLight, fontFamily: fonts.regular }]}>
|
||||||
|
Loading Salesforce Login...
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* WebView */}
|
||||||
|
{!state.error && !state.processing && (
|
||||||
|
<WebView
|
||||||
|
ref={webViewRef}
|
||||||
|
source={{ uri: state.currentUrl }}
|
||||||
|
style={styles.webView}
|
||||||
|
onNavigationStateChange={handleNavigationStateChange}
|
||||||
|
onLoadStart={handleLoadStart}
|
||||||
|
onLoadEnd={handleLoadEnd}
|
||||||
|
onError={handleWebViewError}
|
||||||
|
onHttpError={handleWebViewError}
|
||||||
|
startInLoadingState={true}
|
||||||
|
javaScriptEnabled={true}
|
||||||
|
domStorageEnabled={true}
|
||||||
|
sharedCookiesEnabled={true}
|
||||||
|
thirdPartyCookiesEnabled={true}
|
||||||
|
mixedContentMode="compatibility"
|
||||||
|
originWhitelist={["*"]}
|
||||||
|
allowsInlineMediaPlayback={true}
|
||||||
|
mediaPlaybackRequiresUserAction={false}
|
||||||
|
onShouldStartLoadWithRequest={handleShouldStartLoadWithRequest}
|
||||||
|
setSupportMultipleWindows={false}
|
||||||
|
javaScriptCanOpenWindowsAutomatically={true}
|
||||||
|
renderLoading={() => (
|
||||||
|
<View style={[styles.webViewLoading, { backgroundColor: colors.background }]}>
|
||||||
|
<ActivityIndicator size="large" color="#00A1E0" />
|
||||||
|
<Text style={[styles.webViewLoadingText, { color: colors.textLight, fontFamily: fonts.regular }]}>
|
||||||
|
Loading...
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</SafeAreaView>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
paddingVertical: 12,
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: '#E2E8F0',
|
||||||
|
},
|
||||||
|
headerContent: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
headerTitle: {
|
||||||
|
fontSize: 18,
|
||||||
|
marginLeft: 8,
|
||||||
|
},
|
||||||
|
closeButton: {
|
||||||
|
width: 32,
|
||||||
|
height: 32,
|
||||||
|
borderRadius: 16,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
errorContainer: {
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingHorizontal: 32,
|
||||||
|
},
|
||||||
|
errorTitle: {
|
||||||
|
fontSize: 20,
|
||||||
|
marginTop: 16,
|
||||||
|
marginBottom: 8,
|
||||||
|
},
|
||||||
|
errorMessage: {
|
||||||
|
fontSize: 14,
|
||||||
|
textAlign: 'center',
|
||||||
|
lineHeight: 20,
|
||||||
|
marginBottom: 24,
|
||||||
|
},
|
||||||
|
retryButton: {
|
||||||
|
paddingHorizontal: 24,
|
||||||
|
paddingVertical: 12,
|
||||||
|
borderRadius: 8,
|
||||||
|
},
|
||||||
|
retryButtonText: {
|
||||||
|
fontSize: 16,
|
||||||
|
},
|
||||||
|
loadingOverlay: {
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
zIndex: 1000,
|
||||||
|
},
|
||||||
|
loadingText: {
|
||||||
|
fontSize: 16,
|
||||||
|
marginTop: 16,
|
||||||
|
},
|
||||||
|
webView: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
webViewLoading: {
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
webViewLoadingText: {
|
||||||
|
fontSize: 14,
|
||||||
|
marginTop: 8,
|
||||||
|
},
|
||||||
|
processingOverlay: {
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
zIndex: 2000,
|
||||||
|
},
|
||||||
|
processingModal: {
|
||||||
|
borderRadius: 16,
|
||||||
|
padding: 32,
|
||||||
|
alignItems: 'center',
|
||||||
|
minWidth: 280,
|
||||||
|
maxWidth: 320,
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOffset: { width: 0, height: 4 },
|
||||||
|
shadowOpacity: 0.2,
|
||||||
|
shadowRadius: 8,
|
||||||
|
elevation: 8,
|
||||||
|
},
|
||||||
|
processingIconContainer: {
|
||||||
|
marginBottom: 20,
|
||||||
|
},
|
||||||
|
processingTitle: {
|
||||||
|
fontSize: 20,
|
||||||
|
marginBottom: 12,
|
||||||
|
textAlign: 'center',
|
||||||
|
},
|
||||||
|
processingStep: {
|
||||||
|
fontSize: 14,
|
||||||
|
textAlign: 'center',
|
||||||
|
lineHeight: 20,
|
||||||
|
marginBottom: 20,
|
||||||
|
},
|
||||||
|
processingDots: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
dot: {
|
||||||
|
width: 8,
|
||||||
|
height: 8,
|
||||||
|
borderRadius: 4,
|
||||||
|
marginHorizontal: 4,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default SalesforceAuth;
|
||||||
|
|
||||||
@ -8,9 +8,9 @@ import { clearSelectedService } from '@/modules/integrations/store/integrationsS
|
|||||||
let pendingRequest: any = null;
|
let pendingRequest: any = null;
|
||||||
|
|
||||||
const http = create({
|
const http = create({
|
||||||
// baseURL: 'http://192.168.1.20:4000',
|
// baseURL: 'http://192.168.1.17:4000',
|
||||||
baseURL: 'http://160.187.167.216',
|
// baseURL: 'http://160.187.167.216',
|
||||||
// baseURL: 'https://angry-gifts-shave.loca.lt',
|
baseURL: 'https://512acb53a4a4.ngrok-free.app',
|
||||||
timeout: 10000,
|
timeout: 10000,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -10,6 +10,7 @@ import zohoProjectsSlice from '@/modules/zohoProjects/store/zohoProjectsSlice';
|
|||||||
import profileSlice from '@/modules/profile/store/profileSlice';
|
import profileSlice from '@/modules/profile/store/profileSlice';
|
||||||
import integrationsSlice from '@/modules/integrations/store/integrationsSlice';
|
import integrationsSlice from '@/modules/integrations/store/integrationsSlice';
|
||||||
import crmSlice from '@/modules/crm/zoho/store/crmSlice';
|
import crmSlice from '@/modules/crm/zoho/store/crmSlice';
|
||||||
|
import salesforceCrmSlice from '@/modules/crm/salesforce/store/salesforceCrmSlice';
|
||||||
import zohoBooksSlice from '@/modules/finance/zoho/store/zohoBooksSlice';
|
import zohoBooksSlice from '@/modules/finance/zoho/store/zohoBooksSlice';
|
||||||
|
|
||||||
const rootReducer = combineReducers({
|
const rootReducer = combineReducers({
|
||||||
@ -19,6 +20,7 @@ const rootReducer = combineReducers({
|
|||||||
profile: profileSlice.reducer,
|
profile: profileSlice.reducer,
|
||||||
integrations: integrationsSlice.reducer,
|
integrations: integrationsSlice.reducer,
|
||||||
crm: crmSlice,
|
crm: crmSlice,
|
||||||
|
salesforceCrm: salesforceCrmSlice,
|
||||||
zohoBooks: zohoBooksSlice,
|
zohoBooks: zohoBooksSlice,
|
||||||
ui: uiSlice.reducer,
|
ui: uiSlice.reducer,
|
||||||
});
|
});
|
||||||
@ -26,7 +28,7 @@ const rootReducer = combineReducers({
|
|||||||
const persistConfig = {
|
const persistConfig = {
|
||||||
key: 'root',
|
key: 'root',
|
||||||
storage: AsyncStorage,
|
storage: AsyncStorage,
|
||||||
whitelist: ['auth', 'zohoPeople', 'zohoProjects', 'profile', 'integrations', 'crm', 'zohoBooks'],
|
whitelist: ['auth', 'zohoPeople', 'zohoProjects', 'profile', 'integrations', 'crm', 'salesforceCrm', 'zohoBooks'],
|
||||||
blacklist: ['ui'],
|
blacklist: ['ui'],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user