feat: implement core delivery partner dashboard with live location tracking, socket connectivity, and availability status toggling
This commit is contained in:
parent
a6c713ae8c
commit
98e28742de
27
app/App.tsx
27
app/App.tsx
@ -1,3 +1,4 @@
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { StatusBar, StyleSheet, useColorScheme, View } from 'react-native';
|
||||
import {
|
||||
SafeAreaProvider,
|
||||
@ -5,10 +6,11 @@ import {
|
||||
} from 'react-native-safe-area-context';
|
||||
import RootNavigator from '@navigation/rootNavigator';
|
||||
import { Provider } from 'react-redux';
|
||||
// import { store } from '@store';
|
||||
import { useAppTheme } from '@theme';
|
||||
import { persistor, store } from '@store';
|
||||
import { persistor, store, useAppSelector } from '@store';
|
||||
import { PersistGate } from 'redux-persist/integration/react';
|
||||
import { socketService } from '@services';
|
||||
import { checkPendingOfferThunk } from '@store/commonReducers/delivery';
|
||||
|
||||
function App() {
|
||||
const isDarkMode = useColorScheme() === 'dark';
|
||||
@ -28,6 +30,27 @@ function App() {
|
||||
function AppContent() {
|
||||
const safeAreaInsets = useSafeAreaInsets();
|
||||
const { colors } = useAppTheme();
|
||||
const accessToken = useAppSelector(state => state.auth.accessToken);
|
||||
const prevTokenRef = useRef<string | null>(null);
|
||||
|
||||
// Bootstrap the socket service once with the store reference.
|
||||
// The navigationRef is managed inside RootNavigator, so we pass null here;
|
||||
// it will be injected from rootNavigator.tsx.
|
||||
useEffect(() => {
|
||||
socketService.bootstrap(store, null);
|
||||
}, []);
|
||||
|
||||
// Connect / disconnect the socket when the auth token changes.
|
||||
useEffect(() => {
|
||||
if (accessToken && accessToken !== prevTokenRef.current) {
|
||||
socketService.connect(accessToken);
|
||||
// Check for any delivery offer the server held while the app was closed
|
||||
store.dispatch(checkPendingOfferThunk());
|
||||
} else if (!accessToken && prevTokenRef.current) {
|
||||
socketService.disconnect();
|
||||
}
|
||||
prevTokenRef.current = accessToken;
|
||||
}, [accessToken]);
|
||||
|
||||
return (
|
||||
<View
|
||||
|
||||
@ -1,4 +1,9 @@
|
||||
import { ToggleStatusPayload, ToggleStatusResponse } from '@interfaces';
|
||||
import {
|
||||
DeliveryPartnerLocationRequest,
|
||||
DeliveryPartnerLocationResponse,
|
||||
ToggleStatusPayload,
|
||||
ToggleStatusResponse,
|
||||
} from '@interfaces';
|
||||
import { apiClient } from '@services';
|
||||
|
||||
export const toggleStatus = async (
|
||||
@ -10,3 +15,13 @@ export const toggleStatus = async (
|
||||
);
|
||||
return response;
|
||||
};
|
||||
|
||||
export const deliveryPartnerLocation = async (
|
||||
payload: DeliveryPartnerLocationRequest,
|
||||
): Promise<DeliveryPartnerLocationResponse> => {
|
||||
const response = await apiClient.post<DeliveryPartnerLocationResponse>(
|
||||
'/delivery-partners/location',
|
||||
payload,
|
||||
);
|
||||
return response;
|
||||
};
|
||||
|
||||
40
app/api/deliveryApi.ts
Normal file
40
app/api/deliveryApi.ts
Normal file
@ -0,0 +1,40 @@
|
||||
import {
|
||||
DeliveryAction,
|
||||
RespondToOfferResponse,
|
||||
PendingOfferResponse,
|
||||
ActiveOrdersResponse,
|
||||
} from '@interfaces';
|
||||
import { apiClient } from '@services';
|
||||
|
||||
/**
|
||||
* POST /delivery-partners/deliveries/{id}/respond
|
||||
* Accept or reject a delivery offer.
|
||||
*/
|
||||
export const respondToOffer = async (
|
||||
deliveryId: string,
|
||||
action: DeliveryAction,
|
||||
): Promise<RespondToOfferResponse> => {
|
||||
const response = await apiClient.post<RespondToOfferResponse>(
|
||||
`/delivery-partners/deliveries/${deliveryId}/respond`,
|
||||
{ action },
|
||||
);
|
||||
return response;
|
||||
};
|
||||
|
||||
/**
|
||||
* GET /delivery-partners/deliveries/pending-offer
|
||||
* Check if there is a pending offer for the current partner (e.g. after app restart).
|
||||
*/
|
||||
export const getPendingOffer = async (): Promise<PendingOfferResponse> => {
|
||||
const response = await apiClient.get<PendingOfferResponse>(
|
||||
'/delivery-partners/deliveries/pending-offer',
|
||||
);
|
||||
return response;
|
||||
};
|
||||
|
||||
export const getActiveDeliveries = async (): Promise<ActiveOrdersResponse> => {
|
||||
const response = await apiClient.get<ActiveOrdersResponse>(
|
||||
'/delivery-partners/deliveries/active',
|
||||
);
|
||||
return response;
|
||||
};
|
||||
@ -2,3 +2,4 @@ export * from './authApi';
|
||||
export * from './onBoardApi';
|
||||
export * from './kycApi';
|
||||
export * from './dashboardApi';
|
||||
export * from './deliveryApi';
|
||||
|
||||
@ -11,8 +11,8 @@ interface OrderCardProps {
|
||||
pickupAddress: string;
|
||||
dropName: string;
|
||||
dropAddress: string;
|
||||
amount: number;
|
||||
distance: number;
|
||||
amount: string;
|
||||
distance: string;
|
||||
itemCount?: number;
|
||||
paymentType?: string;
|
||||
onPress?: () => void;
|
||||
|
||||
@ -12,15 +12,17 @@ import { getStyles } from './arrivedAtStoreScreen.styles';
|
||||
export const ArrivedAtStoreScreen: React.FC = () => {
|
||||
const { colors } = useAppTheme();
|
||||
const styles = getStyles(colors);
|
||||
const navigation = useNavigation<NativeStackNavigationProp<AppStackParamList>>();
|
||||
const navigation =
|
||||
useNavigation<NativeStackNavigationProp<AppStackParamList>>();
|
||||
|
||||
const activeJob = useAppSelector((state) => state.job.activeJob);
|
||||
// const activeJob = useAppSelector(state => state.job.activeJob);
|
||||
const { activeDeliveries } = useAppSelector(state => state.deliveries);
|
||||
|
||||
const handleConfirmArrival = () => {
|
||||
navigation.navigate(RouteNames.ConfirmPickup);
|
||||
};
|
||||
|
||||
if (!activeJob) return null;
|
||||
// if (!activeJob) return null;
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
@ -32,8 +34,12 @@ export const ArrivedAtStoreScreen: React.FC = () => {
|
||||
{/* Arrived Card */}
|
||||
<View style={styles.bottomCard}>
|
||||
<Text style={styles.cardTitle}>You've arrived</Text>
|
||||
<Text style={styles.storeName}>{activeJob.pickupName}</Text>
|
||||
<Text style={styles.storeAddress}>{activeJob.pickupAddress}</Text>
|
||||
<Text style={styles.storeName}>
|
||||
{activeDeliveries?.order?.pickupAddress?.city}
|
||||
</Text>
|
||||
<Text style={styles.storeAddress}>
|
||||
{activeDeliveries?.order?.pickupAddress?.addressLine1}
|
||||
</Text>
|
||||
|
||||
<PrimaryButton
|
||||
title="Confirm Arrival"
|
||||
|
||||
@ -14,9 +14,10 @@ export const ConfirmPickupScreen: React.FC = () => {
|
||||
const { colors } = useAppTheme();
|
||||
const styles = getStyles(colors);
|
||||
const dispatch = useAppDispatch();
|
||||
const navigation = useNavigation<NativeStackNavigationProp<AppStackParamList>>();
|
||||
const navigation =
|
||||
useNavigation<NativeStackNavigationProp<AppStackParamList>>();
|
||||
|
||||
const activeJob = useAppSelector((state) => state.job.activeJob);
|
||||
const activeJob = useAppSelector(state => state.job.activeJob);
|
||||
|
||||
// Togglable checklist for food/merchandise items
|
||||
const [checkedItems, setCheckedItems] = useState<{ [key: number]: boolean }>({
|
||||
@ -30,7 +31,7 @@ export const ConfirmPickupScreen: React.FC = () => {
|
||||
];
|
||||
|
||||
const handleToggleCheck = (id: number) => {
|
||||
setCheckedItems((prev) => ({
|
||||
setCheckedItems(prev => ({
|
||||
...prev,
|
||||
[id]: !prev[id],
|
||||
}));
|
||||
@ -38,9 +39,12 @@ export const ConfirmPickupScreen: React.FC = () => {
|
||||
|
||||
const handlePickedUp = () => {
|
||||
// Ensure all items are checked before picking up
|
||||
const allChecked = items.every((item) => checkedItems[item.id]);
|
||||
const allChecked = items.every(item => checkedItems[item.id]);
|
||||
if (!allChecked) {
|
||||
Alert.alert('Incomplete Verification', 'Please confirm that you have checked all items before picking up.');
|
||||
Alert.alert(
|
||||
'Incomplete Verification',
|
||||
'Please confirm that you have checked all items before picking up.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@ -49,10 +53,13 @@ export const ConfirmPickupScreen: React.FC = () => {
|
||||
};
|
||||
|
||||
const handleReportIssue = () => {
|
||||
Alert.alert('Report Issue', 'Helpdesk notified. Support agent will contact you shortly.');
|
||||
Alert.alert(
|
||||
'Report Issue',
|
||||
'Helpdesk notified. Support agent will contact you shortly.',
|
||||
);
|
||||
};
|
||||
|
||||
if (!activeJob) return null;
|
||||
// if (!activeJob) return null;
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
@ -62,10 +69,13 @@ export const ConfirmPickupScreen: React.FC = () => {
|
||||
onBack={() => navigation.goBack()}
|
||||
/>
|
||||
|
||||
<ScrollView contentContainerStyle={styles.scrollContainer} showsVerticalScrollIndicator={false}>
|
||||
<ScrollView
|
||||
contentContainerStyle={styles.scrollContainer}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
{/* Order ID Banner */}
|
||||
<View style={styles.orderHeader}>
|
||||
<Text style={styles.orderIdText}>Order ID: {activeJob.orderId}</Text>
|
||||
<Text style={styles.orderIdText}>Order ID: {activeJob?.orderId}</Text>
|
||||
<View style={styles.paymentTag}>
|
||||
<Text style={styles.paymentTagText}>ONLINE PAID</Text>
|
||||
</View>
|
||||
@ -74,21 +84,28 @@ export const ConfirmPickupScreen: React.FC = () => {
|
||||
{/* Merchant Info */}
|
||||
<View style={styles.merchantInfoCard}>
|
||||
<Text style={styles.merchantLabel}>Pickup Store</Text>
|
||||
<Text style={styles.merchantName}>{activeJob.pickupName}</Text>
|
||||
<Text style={styles.merchantName}>{activeJob?.pickupName}</Text>
|
||||
</View>
|
||||
|
||||
{/* Items Checklist */}
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>Verify Items ({items.length})</Text>
|
||||
{items.map((item) => (
|
||||
{items.map(item => (
|
||||
<TouchableOpacity
|
||||
key={item.id}
|
||||
style={styles.itemRow}
|
||||
activeOpacity={0.8}
|
||||
onPress={() => handleToggleCheck(item.id)}
|
||||
>
|
||||
<View style={[styles.checkbox, checkedItems[item.id] && styles.checkboxChecked]}>
|
||||
{checkedItems[item.id] && <Text style={styles.checkSign}>✓</Text>}
|
||||
<View
|
||||
style={[
|
||||
styles.checkbox,
|
||||
checkedItems[item.id] && styles.checkboxChecked,
|
||||
]}
|
||||
>
|
||||
{checkedItems[item.id] && (
|
||||
<Text style={styles.checkSign}>✓</Text>
|
||||
)}
|
||||
</View>
|
||||
<Text style={styles.itemNameText}>{item.name}</Text>
|
||||
<Text style={styles.itemQtyText}>x{item.qty}</Text>
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import React, { useCallback, useEffect, useRef } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
@ -6,21 +6,25 @@ import {
|
||||
TouchableOpacity,
|
||||
Alert,
|
||||
Switch,
|
||||
InteractionManager,
|
||||
} from 'react-native';
|
||||
import { useAppTheme, borderRadius, spacing, shadow, typography } from '@theme';
|
||||
import { PrimaryButton } from '@components';
|
||||
import { BellIcon, PersonIcon, WalletIcon, ClipboardIcon } from '@icons';
|
||||
import { useAppDispatch, useAppSelector } from '@store';
|
||||
import { setOnlineStatus } from '@store/commonReducers/auth';
|
||||
import { setNewJobOffer } from '@store/commonReducers/job';
|
||||
import { mockJobOffer } from '@store/mockData/mockJobs';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||
import { AppStackParamList } from '@navigation/navigationTypes';
|
||||
import { RouteNames, JobStatus } from '@utils/constants';
|
||||
import { formatCurrency } from '@utils/formatters';
|
||||
import { getStyles } from './dashboardScreen.styles';
|
||||
import { toggleStatus } from './thunk';
|
||||
import { toggleStatus, updateDeliveryPartnerLocation } from './thunk';
|
||||
import {
|
||||
getCurrentLocationWithAddress,
|
||||
showPermissionDeniedAlert,
|
||||
socketService,
|
||||
} from '@services';
|
||||
|
||||
export const DashboardScreen: React.FC = () => {
|
||||
const { colors } = useAppTheme();
|
||||
@ -34,6 +38,7 @@ export const DashboardScreen: React.FC = () => {
|
||||
const { availabiltyStatus, loading: toggleLoading } = useAppSelector(
|
||||
state => state.dashboard,
|
||||
);
|
||||
const { user } = useAppSelector(state => state.auth);
|
||||
const authIsOnline = useAppSelector(state => state.auth.isOnline);
|
||||
const isOnline = availabiltyStatus
|
||||
? availabiltyStatus.availabilityStatus === 'ONLINE'
|
||||
@ -42,28 +47,57 @@ export const DashboardScreen: React.FC = () => {
|
||||
useAppSelector(state => state.earnings);
|
||||
const { jobStatus, activeJob } = useAppSelector(state => state.job);
|
||||
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const locationIntervalRef = useRef<ReturnType<typeof setInterval> | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const sendLocationUpdate = useCallback(async () => {
|
||||
try {
|
||||
const result = await getCurrentLocationWithAddress();
|
||||
const coords = result.coords;
|
||||
|
||||
dispatch(
|
||||
updateDeliveryPartnerLocation({
|
||||
latitude: coords.latitude,
|
||||
longitude: coords.longitude,
|
||||
accuracyMeters: coords.accuracy ?? 0,
|
||||
speedKph: coords.speed ? coords.speed * 3.6 : 0, // convert m/s -> km/h if needed
|
||||
heading: coords.heading ?? 0,
|
||||
}),
|
||||
);
|
||||
} catch (err) {
|
||||
console.log('--------location update error------', err);
|
||||
}
|
||||
}, [dispatch]);
|
||||
|
||||
// Background simulation: when online and idle, trigger new order offer after 12 seconds
|
||||
useEffect(() => {
|
||||
if (isOnline && jobStatus === JobStatus.Idle) {
|
||||
timerRef.current = setTimeout(() => {
|
||||
dispatch(setNewJobOffer(mockJobOffer));
|
||||
navigation.navigate(RouteNames.NewJobRequest);
|
||||
}, 12000);
|
||||
if (isOnline) {
|
||||
// Fire one immediately on going online, then every 15s
|
||||
sendLocationUpdate();
|
||||
locationIntervalRef.current = setInterval(sendLocationUpdate, 15000);
|
||||
} else {
|
||||
if (timerRef.current) {
|
||||
clearTimeout(timerRef.current);
|
||||
timerRef.current = null;
|
||||
if (locationIntervalRef.current) {
|
||||
clearInterval(locationIntervalRef.current);
|
||||
locationIntervalRef.current = null;
|
||||
}
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (timerRef.current) {
|
||||
clearTimeout(timerRef.current);
|
||||
if (locationIntervalRef.current) {
|
||||
clearInterval(locationIntervalRef.current);
|
||||
locationIntervalRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [isOnline, jobStatus, dispatch, navigation]);
|
||||
}, [isOnline, sendLocationUpdate]);
|
||||
|
||||
// Notify the socket server when online/offline status changes
|
||||
useEffect(() => {
|
||||
if (isOnline) {
|
||||
socketService.goOnline();
|
||||
} else {
|
||||
socketService.goOffline();
|
||||
}
|
||||
}, [isOnline]);
|
||||
|
||||
const handleToggleOnline = () => {
|
||||
const newStatus = isOnline ? 'OFFLINE' : 'ONLINE';
|
||||
@ -88,22 +122,6 @@ export const DashboardScreen: React.FC = () => {
|
||||
});
|
||||
};
|
||||
|
||||
const handleSimulateOffer = () => {
|
||||
if (!isOnline) {
|
||||
Alert.alert('Go Online', 'Please go online to receive order offers.');
|
||||
return;
|
||||
}
|
||||
if (jobStatus !== JobStatus.Idle) {
|
||||
Alert.alert(
|
||||
'Active Delivery',
|
||||
'You already have an active job offer or order.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
dispatch(setNewJobOffer(mockJobOffer));
|
||||
navigation.navigate(RouteNames.NewJobRequest);
|
||||
};
|
||||
|
||||
const handleActiveJobBannerPress = () => {
|
||||
// Navigate back to the screen corresponding to the current job step
|
||||
switch (jobStatus) {
|
||||
@ -151,7 +169,7 @@ export const DashboardScreen: React.FC = () => {
|
||||
<View style={styles.header}>
|
||||
<View>
|
||||
<Text style={styles.greetingText}>Good Morning ☀️</Text>
|
||||
<Text style={styles.nameText}>Rahul Kumar</Text>
|
||||
<Text style={styles.nameText}>{user?.name}</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.toggleWrapper}>
|
||||
@ -281,17 +299,6 @@ export const DashboardScreen: React.FC = () => {
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Developer Simulation trigger */}
|
||||
<TouchableOpacity
|
||||
style={styles.simulateBtn}
|
||||
activeOpacity={0.8}
|
||||
onPress={handleSimulateOffer}
|
||||
>
|
||||
<Text style={styles.simulateBtnText}>
|
||||
⚡ Simulate Immediate Job Offer
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<PrimaryButton
|
||||
title="Go Offline"
|
||||
onPress={handleToggleOnline}
|
||||
|
||||
@ -1,17 +1,24 @@
|
||||
import { ToggleStatusResponse } from '@interfaces';
|
||||
import {
|
||||
DeliveryPartnerLocationResponse,
|
||||
ToggleStatusResponse,
|
||||
} from '@interfaces';
|
||||
import { createReducer } from '@reduxjs/toolkit';
|
||||
import { toggleStatus } from './thunk';
|
||||
import { toggleStatus, updateDeliveryPartnerLocation } from './thunk';
|
||||
|
||||
export interface ToggleState {
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
availabiltyStatus: ToggleStatusResponse | null;
|
||||
deliveryPartnerLocation: DeliveryPartnerLocationResponse | null;
|
||||
deliveryLocError: string | null;
|
||||
}
|
||||
|
||||
export const initialState: ToggleState = {
|
||||
loading: false,
|
||||
error: null,
|
||||
availabiltyStatus: null,
|
||||
deliveryPartnerLocation: null,
|
||||
deliveryLocError: null,
|
||||
};
|
||||
|
||||
export const dashBoardReducer = createReducer(initialState, builder => {
|
||||
@ -27,5 +34,11 @@ export const dashBoardReducer = createReducer(initialState, builder => {
|
||||
.addCase(toggleStatus.rejected, (state, action) => {
|
||||
state.loading = false;
|
||||
state.error = action.payload as string;
|
||||
})
|
||||
.addCase(updateDeliveryPartnerLocation.fulfilled, (state, action) => {
|
||||
state.deliveryPartnerLocation = action.payload;
|
||||
})
|
||||
.addCase(updateDeliveryPartnerLocation.rejected, (state, action) => {
|
||||
state.deliveryLocError = action.payload as string;
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,6 +1,9 @@
|
||||
import { createAsyncThunk } from '@reduxjs/toolkit';
|
||||
import { toggleStatus as toggleStatusApi } from '@api';
|
||||
import { ToggleStatusPayload } from '@interfaces';
|
||||
import { deliveryPartnerLocation, toggleStatus as toggleStatusApi } from '@api';
|
||||
import {
|
||||
DeliveryPartnerLocationRequest,
|
||||
ToggleStatusPayload,
|
||||
} from '@interfaces';
|
||||
|
||||
export const toggleStatus = createAsyncThunk(
|
||||
'dashboard/toggleStatus',
|
||||
@ -13,3 +16,15 @@ export const toggleStatus = createAsyncThunk(
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
export const updateDeliveryPartnerLocation = createAsyncThunk(
|
||||
'dashboard/updateDeliveryPartnerLocation',
|
||||
async (payload: DeliveryPartnerLocationRequest, { rejectWithValue }) => {
|
||||
try {
|
||||
const response = await deliveryPartnerLocation(payload);
|
||||
return response;
|
||||
} catch (error: unknown) {
|
||||
return rejectWithValue(error);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
@ -1,2 +1,4 @@
|
||||
export { default } from './jobsScreen';
|
||||
export * from './jobsScreen';
|
||||
export * from './thunk';
|
||||
export * from './reducer';
|
||||
|
||||
@ -1,17 +1,30 @@
|
||||
import React, { useState } from 'react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { View, Text, ScrollView, TouchableOpacity } from 'react-native';
|
||||
import { useAppTheme } from '@theme';
|
||||
import { OrderCard, EmptyState } from '@components';
|
||||
import { useAppSelector } from '@store';
|
||||
import { useAppDispatch, useAppSelector } from '@store';
|
||||
import { ClipboardIcon } from '@icons';
|
||||
import { getStyles } from './jobsScreen.styles';
|
||||
import { getAllActiveDeliveries } from './thunk';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||
import { AppStackParamList } from '@navigation/navigationTypes';
|
||||
import { RouteNames } from '@utils/constants';
|
||||
|
||||
export const JobsScreen: React.FC = () => {
|
||||
const navigation =
|
||||
useNavigation<NativeStackNavigationProp<AppStackParamList>>();
|
||||
const { colors } = useAppTheme();
|
||||
const styles = getStyles(colors);
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const [activeTab, setActiveTab] = useState<'active' | 'history'>('active');
|
||||
const { activeJob, jobHistory } = useAppSelector((state) => state.job);
|
||||
const { activeJob, jobHistory } = useAppSelector(state => state.job);
|
||||
const { activeDeliveries } = useAppSelector(state => state.deliveries);
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(getAllActiveDeliveries());
|
||||
}, [dispatch]);
|
||||
|
||||
// Default history mock jobs
|
||||
const defaultHistory = [
|
||||
@ -20,8 +33,8 @@ export const JobsScreen: React.FC = () => {
|
||||
pickupAddress: 'MG Road, Bengaluru',
|
||||
dropName: 'Rohit Sharma',
|
||||
dropAddress: 'Koramangala 4th Block, Bengaluru',
|
||||
amount: 78.0,
|
||||
distance: 1.2,
|
||||
amount: '78.0',
|
||||
distance: '1.2',
|
||||
itemCount: 2,
|
||||
paymentType: 'online',
|
||||
},
|
||||
@ -30,8 +43,8 @@ export const JobsScreen: React.FC = () => {
|
||||
pickupAddress: 'Indiranagar, Bengaluru',
|
||||
dropName: 'Aishwarya Sen',
|
||||
dropAddress: 'Domlur Stage 2, Bengaluru',
|
||||
amount: 95.0,
|
||||
distance: 3.8,
|
||||
amount: '95.0',
|
||||
distance: '3.8',
|
||||
itemCount: 3,
|
||||
paymentType: 'cod',
|
||||
},
|
||||
@ -40,8 +53,8 @@ export const JobsScreen: React.FC = () => {
|
||||
pickupAddress: 'Koramangala 5th Block',
|
||||
dropName: 'Sameer Khan',
|
||||
dropAddress: 'HSR Layout Sector 3, Bengaluru',
|
||||
amount: 110.0,
|
||||
distance: 4.5,
|
||||
amount: '110.0',
|
||||
distance: '4.5',
|
||||
itemCount: 1,
|
||||
paymentType: 'online',
|
||||
},
|
||||
@ -60,7 +73,12 @@ export const JobsScreen: React.FC = () => {
|
||||
activeOpacity={0.8}
|
||||
onPress={() => setActiveTab('active')}
|
||||
>
|
||||
<Text style={[styles.tabText, activeTab === 'active' && styles.tabTextActive]}>
|
||||
<Text
|
||||
style={[
|
||||
styles.tabText,
|
||||
activeTab === 'active' && styles.tabTextActive,
|
||||
]}
|
||||
>
|
||||
Active / Upcoming
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
@ -70,32 +88,49 @@ export const JobsScreen: React.FC = () => {
|
||||
activeOpacity={0.8}
|
||||
onPress={() => setActiveTab('history')}
|
||||
>
|
||||
<Text style={[styles.tabText, activeTab === 'history' && styles.tabTextActive]}>
|
||||
<Text
|
||||
style={[
|
||||
styles.tabText,
|
||||
activeTab === 'history' && styles.tabTextActive,
|
||||
]}
|
||||
>
|
||||
History
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<ScrollView contentContainerStyle={styles.listContainer} showsVerticalScrollIndicator={false}>
|
||||
<ScrollView
|
||||
contentContainerStyle={styles.listContainer}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
{activeTab === 'active' ? (
|
||||
// Active job tab
|
||||
activeJob ? (
|
||||
activeDeliveries ? (
|
||||
<View style={styles.cardWrapper}>
|
||||
<OrderCard
|
||||
pickupName={activeJob.pickupName}
|
||||
pickupAddress={activeJob.pickupAddress}
|
||||
dropName={activeJob.dropName}
|
||||
dropAddress={activeJob.dropAddress}
|
||||
amount={activeJob.amount}
|
||||
distance={activeJob.distance}
|
||||
itemCount={activeJob.itemCount}
|
||||
paymentType={activeJob.paymentType}
|
||||
pickupName={activeDeliveries?.order?.pickupAddress?.city}
|
||||
pickupAddress={
|
||||
activeDeliveries?.order?.pickupAddress?.addressLine1
|
||||
}
|
||||
dropName={activeDeliveries?.order?.dropAddress?.city}
|
||||
dropAddress={activeDeliveries?.order?.dropAddress?.addressLine1}
|
||||
amount={activeDeliveries?.order?.totalAmount}
|
||||
distance={activeDeliveries?.estimatedDistanceKm}
|
||||
itemCount={activeDeliveries?.order?.orderItems?.length}
|
||||
paymentType={activeDeliveries?.order?.paymentMethod}
|
||||
onPress={() =>
|
||||
navigation.navigate(RouteNames.ArrivedAtStore, {
|
||||
jobId: activeDeliveries?.orderId,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
) : (
|
||||
<View style={styles.emptyStateContainer}>
|
||||
<ClipboardIcon size={48} color={colors.placeholder} />
|
||||
<Text style={styles.emptyStateText}>No active deliveries right now</Text>
|
||||
<Text style={styles.emptyStateText}>
|
||||
No active deliveries right now
|
||||
</Text>
|
||||
</View>
|
||||
)
|
||||
) : (
|
||||
|
||||
31
app/features/screens/jobsScreen/reducer.ts
Normal file
31
app/features/screens/jobsScreen/reducer.ts
Normal file
@ -0,0 +1,31 @@
|
||||
import { ActiveOrdersResponse } from '@interfaces';
|
||||
import { createReducer } from '@reduxjs/toolkit';
|
||||
import { getAllActiveDeliveries } from './thunk';
|
||||
|
||||
export interface ActiveDeliveriesState {
|
||||
activeDeliveries: ActiveOrdersResponse | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
const initialState: ActiveDeliveriesState = {
|
||||
activeDeliveries: null,
|
||||
loading: false,
|
||||
error: null,
|
||||
};
|
||||
|
||||
export const activeDeliveriesReducer = createReducer(initialState, builder => {
|
||||
builder
|
||||
.addCase(getAllActiveDeliveries.pending, state => {
|
||||
state.loading = true;
|
||||
})
|
||||
.addCase(getAllActiveDeliveries.fulfilled, (state, action) => {
|
||||
state.loading = false;
|
||||
state.activeDeliveries = action.payload;
|
||||
// console.log(action.payload);
|
||||
})
|
||||
.addCase(getAllActiveDeliveries.rejected, (state, action) => {
|
||||
state.loading = false;
|
||||
state.error = action.payload as string;
|
||||
});
|
||||
});
|
||||
14
app/features/screens/jobsScreen/thunk.ts
Normal file
14
app/features/screens/jobsScreen/thunk.ts
Normal file
@ -0,0 +1,14 @@
|
||||
import { getActiveDeliveries } from '@api';
|
||||
import { createAsyncThunk } from '@reduxjs/toolkit';
|
||||
|
||||
export const getAllActiveDeliveries = createAsyncThunk(
|
||||
'jobs/getAllActiveDeliveries',
|
||||
async (_, { rejectWithValue }) => {
|
||||
try {
|
||||
const response = await getActiveDeliveries();
|
||||
return response;
|
||||
} catch (error) {
|
||||
return rejectWithValue(error);
|
||||
}
|
||||
},
|
||||
);
|
||||
@ -1,6 +1,8 @@
|
||||
import { StyleSheet } from 'react-native';
|
||||
import { typography, spacing, borderRadius, shadow } from '@theme';
|
||||
|
||||
const TIMER_SIZE = 56;
|
||||
|
||||
export const getStyles = (colors: any) =>
|
||||
StyleSheet.create({
|
||||
container: {
|
||||
@ -27,21 +29,45 @@ export const getStyles = (colors: any) =>
|
||||
fontWeight: typography.fontWeight.bold,
|
||||
color: colors.text,
|
||||
},
|
||||
timerContainer: {
|
||||
width: 44,
|
||||
height: 44,
|
||||
borderRadius: 22,
|
||||
|
||||
// ── Circular Timer (border-based) ─────────────────────────────────────
|
||||
timerWrapper: {
|
||||
width: TIMER_SIZE,
|
||||
height: TIMER_SIZE,
|
||||
borderRadius: TIMER_SIZE / 2,
|
||||
borderWidth: 3,
|
||||
borderColor: colors.primary,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
backgroundColor: colors.primaryLight,
|
||||
},
|
||||
timerText: {
|
||||
fontSize: typography.fontSize.sm,
|
||||
fontSize: typography.fontSize.md,
|
||||
fontWeight: typography.fontWeight.bold,
|
||||
color: colors.primary,
|
||||
},
|
||||
|
||||
// ── Earnings Badge ────────────────────────────────────────────────────
|
||||
earningsBadge: {
|
||||
borderRadius: borderRadius.md,
|
||||
paddingVertical: spacing.md,
|
||||
paddingHorizontal: spacing.lg,
|
||||
marginBottom: spacing.lg,
|
||||
alignItems: 'center',
|
||||
overflow: 'hidden',
|
||||
},
|
||||
earningsBadgeLabel: {
|
||||
fontSize: 10,
|
||||
fontWeight: 'bold' as const,
|
||||
textTransform: 'uppercase' as const,
|
||||
color: 'rgba(255,255,255,0.75)',
|
||||
marginBottom: 2,
|
||||
},
|
||||
earningsBadgeValue: {
|
||||
fontSize: 28,
|
||||
fontWeight: typography.fontWeight.bold,
|
||||
color: '#fff',
|
||||
},
|
||||
|
||||
// ── Route Card ────────────────────────────────────────────────────────
|
||||
routeContainer: {
|
||||
backgroundColor: colors.inputBg,
|
||||
borderRadius: borderRadius.md,
|
||||
@ -53,6 +79,8 @@ export const getStyles = (colors: any) =>
|
||||
spacer: {
|
||||
height: spacing.md,
|
||||
},
|
||||
|
||||
// ── Details Row ───────────────────────────────────────────────────────
|
||||
detailsRow: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
@ -84,6 +112,8 @@ export const getStyles = (colors: any) =>
|
||||
height: 28,
|
||||
backgroundColor: colors.border,
|
||||
},
|
||||
|
||||
// ── Buttons ───────────────────────────────────────────────────────────
|
||||
btnRow: {
|
||||
flexDirection: 'row',
|
||||
gap: 12,
|
||||
|
||||
@ -1,9 +1,12 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { View, Text, Alert } from 'react-native';
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { View, Text, Animated, Easing } from 'react-native';
|
||||
import { useAppTheme } from '@theme';
|
||||
import { PrimaryButton, LocationRow } from '@components';
|
||||
import { useAppDispatch, useAppSelector } from '@store';
|
||||
import { acceptJobThunk, rejectJobThunk } from '@store/commonReducers/job';
|
||||
import {
|
||||
respondToOfferThunk,
|
||||
clearDeliveryOffer,
|
||||
} from '@store/commonReducers/delivery';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||
import { AppStackParamList } from '@navigation/navigationTypes';
|
||||
@ -11,101 +14,188 @@ import { RouteNames } from '@utils/constants';
|
||||
import { formatCurrency } from '@utils/formatters';
|
||||
import { getStyles } from './newJobRequestScreen.styles';
|
||||
|
||||
const TOTAL_SECONDS = 15;
|
||||
|
||||
export const NewJobRequestScreen: React.FC = () => {
|
||||
const { colors } = useAppTheme();
|
||||
const styles = getStyles(colors);
|
||||
const dispatch = useAppDispatch();
|
||||
const navigation = useNavigation<NativeStackNavigationProp<AppStackParamList>>();
|
||||
const navigation =
|
||||
useNavigation<NativeStackNavigationProp<AppStackParamList>>();
|
||||
|
||||
const newJobOffer = useAppSelector((state) => state.job.newJobOffer);
|
||||
const [secondsLeft, setSecondsLeft] = useState(15);
|
||||
// Read from the socket-driven delivery slice
|
||||
const currentOffer = useAppSelector(state => state.delivery.currentOffer);
|
||||
const respondLoading = useAppSelector(
|
||||
state => state.delivery.respondLoading,
|
||||
);
|
||||
|
||||
const [secondsLeft, setSecondsLeft] = useState(TOTAL_SECONDS);
|
||||
|
||||
// Animated values
|
||||
const pulseAnim = useRef(new Animated.Value(1)).current;
|
||||
|
||||
// ── Countdown timer ──────────────────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
if (secondsLeft <= 0) {
|
||||
handleReject();
|
||||
return;
|
||||
}
|
||||
const timer = setInterval(() => {
|
||||
setSecondsLeft((prev) => prev - 1);
|
||||
setSecondsLeft(prev => prev - 1);
|
||||
}, 1000);
|
||||
return () => clearInterval(timer);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [secondsLeft]);
|
||||
|
||||
const handleAccept = () => {
|
||||
if (newJobOffer?.jobId) {
|
||||
dispatch(acceptJobThunk(newJobOffer.jobId));
|
||||
// ── Pulse animation on Accept button when < 5 s remain ──────────────────
|
||||
useEffect(() => {
|
||||
if (secondsLeft <= 5 && secondsLeft > 0) {
|
||||
Animated.loop(
|
||||
Animated.sequence([
|
||||
Animated.timing(pulseAnim, {
|
||||
toValue: 1.06,
|
||||
duration: 400,
|
||||
easing: Easing.inOut(Easing.ease),
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(pulseAnim, {
|
||||
toValue: 1,
|
||||
duration: 400,
|
||||
easing: Easing.inOut(Easing.ease),
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
]),
|
||||
).start();
|
||||
} else {
|
||||
pulseAnim.setValue(1);
|
||||
}
|
||||
Alert.alert('Job Accepted', 'Heading to Cafe Coffee Day pickup location.', [
|
||||
{
|
||||
text: 'OK',
|
||||
onPress: () => navigation.navigate(RouteNames.OrderAccepted),
|
||||
},
|
||||
]);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [secondsLeft <= 5]);
|
||||
|
||||
// ── Handlers ─────────────────────────────────────────────────────────────
|
||||
const handleAccept = () => {
|
||||
if (!currentOffer) return;
|
||||
dispatch(
|
||||
respondToOfferThunk({
|
||||
deliveryId: currentOffer.deliveryId,
|
||||
action: 'ACCEPT',
|
||||
}),
|
||||
)
|
||||
.unwrap()
|
||||
.then(() => {
|
||||
navigation.navigate(RouteNames.OrderAccepted);
|
||||
})
|
||||
.catch(() => {
|
||||
// stay on screen — respondError will be set in redux
|
||||
});
|
||||
};
|
||||
|
||||
const handleReject = () => {
|
||||
if (newJobOffer?.jobId) {
|
||||
dispatch(rejectJobThunk(newJobOffer.jobId));
|
||||
if (currentOffer) {
|
||||
dispatch(
|
||||
respondToOfferThunk({
|
||||
deliveryId: currentOffer.deliveryId,
|
||||
action: 'REJECT',
|
||||
}),
|
||||
);
|
||||
}
|
||||
navigation.navigate(RouteNames.BottomTabs, { screen: RouteNames.Dashboard });
|
||||
dispatch(clearDeliveryOffer());
|
||||
navigation.navigate(RouteNames.BottomTabs, {
|
||||
screen: RouteNames.Dashboard,
|
||||
});
|
||||
};
|
||||
|
||||
if (!newJobOffer) return null;
|
||||
if (!currentOffer) return null;
|
||||
|
||||
// Timer text color: warning when < 5s
|
||||
const timerColor = secondsLeft <= 5 ? colors.error : colors.primary;
|
||||
const timerBorderColor =
|
||||
secondsLeft <= 5 ? colors.error : colors.primary;
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<View style={styles.modalContent}>
|
||||
{/* Header Row with Title and Countdown */}
|
||||
{/* ── Header Row ─────────────────────────────────────────────────── */}
|
||||
<View style={styles.headerRow}>
|
||||
<Text style={styles.modalTitle}>New Delivery Request</Text>
|
||||
<View style={styles.timerContainer}>
|
||||
<Text style={styles.timerText}>{secondsLeft}s</Text>
|
||||
|
||||
{/* Circular countdown timer (pure RN — no SVG needed) */}
|
||||
<View
|
||||
style={[
|
||||
styles.timerWrapper,
|
||||
{ borderColor: timerBorderColor },
|
||||
]}
|
||||
>
|
||||
<Text style={[styles.timerText, { color: timerColor }]}>
|
||||
{secondsLeft}s
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Route Details Card */}
|
||||
{/* ── Earnings Badge ─────────────────────────────────────────────── */}
|
||||
<View
|
||||
style={[
|
||||
styles.earningsBadge,
|
||||
{ backgroundColor: colors.primary },
|
||||
]}
|
||||
>
|
||||
<Text style={styles.earningsBadgeLabel}>Estimated Earnings</Text>
|
||||
<Text style={styles.earningsBadgeValue}>
|
||||
{formatCurrency(currentOffer.totalAmount)}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* ── Route Details ──────────────────────────────────────────────── */}
|
||||
<View style={styles.routeContainer}>
|
||||
<LocationRow
|
||||
type="pickup"
|
||||
name="Cafe Coffee Day"
|
||||
address={newJobOffer.pickupAddress}
|
||||
name="Pickup Location"
|
||||
address={currentOffer.pickupAddress.label}
|
||||
/>
|
||||
<View style={styles.spacer} />
|
||||
<LocationRow
|
||||
type="drop"
|
||||
name="Rohit Sharma"
|
||||
address={newJobOffer.dropAddress}
|
||||
name="Drop Location"
|
||||
address={currentOffer.dropAddress.label}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Earnings & Distance Details */}
|
||||
{/* ── Distance & Order ID ────────────────────────────────────────── */}
|
||||
<View style={styles.detailsRow}>
|
||||
<View style={styles.detailBox}>
|
||||
<Text style={styles.detailLabel}>Estimated Earnings</Text>
|
||||
<Text style={styles.detailValue}>{formatCurrency(newJobOffer.amount)}</Text>
|
||||
<Text style={styles.detailLabel}>Distance</Text>
|
||||
<Text style={styles.detailValue}>
|
||||
{currentOffer.distanceKm} km
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.divider} />
|
||||
|
||||
<View style={styles.detailBox}>
|
||||
<Text style={styles.detailLabel}>Distance</Text>
|
||||
<Text style={styles.detailValue}>{newJobOffer.distance} km</Text>
|
||||
<Text style={styles.detailLabel}>Order</Text>
|
||||
<Text style={styles.detailValue}>#{currentOffer.orderId}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Button Actions */}
|
||||
{/* ── Action Buttons ─────────────────────────────────────────────── */}
|
||||
<View style={styles.btnRow}>
|
||||
<PrimaryButton
|
||||
title="Reject"
|
||||
onPress={handleReject}
|
||||
style={styles.rejectBtn}
|
||||
textStyle={{ color: colors.text }}
|
||||
disabled={respondLoading}
|
||||
/>
|
||||
<Animated.View
|
||||
style={[{ flex: 2, transform: [{ scale: pulseAnim }] }]}
|
||||
>
|
||||
<PrimaryButton
|
||||
title="Accept"
|
||||
title={respondLoading ? 'Accepting...' : 'Accept'}
|
||||
onPress={handleAccept}
|
||||
style={styles.acceptBtn}
|
||||
style={[styles.acceptBtn, { flex: undefined }]}
|
||||
disabled={respondLoading}
|
||||
/>
|
||||
</Animated.View>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
@ -7,3 +7,17 @@ export interface ToggleStatusResponse {
|
||||
availabilityStatus: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface DeliveryPartnerLocationRequest {
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
accuracyMeters: number;
|
||||
speedKph: number;
|
||||
heading: number;
|
||||
}
|
||||
|
||||
export interface DeliveryPartnerLocationResponse {
|
||||
message: string;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
}
|
||||
|
||||
27
app/interfaces/delivery.ts
Normal file
27
app/interfaces/delivery.ts
Normal file
@ -0,0 +1,27 @@
|
||||
// Socket offer payload (delivery_offer event)
|
||||
export interface DeliveryOffer {
|
||||
deliveryId: string;
|
||||
orderId: string;
|
||||
pickupAddress: { label: string; lat: number; lng: number };
|
||||
dropAddress: { label: string; lat: number; lng: number };
|
||||
totalAmount: number;
|
||||
distanceKm: number;
|
||||
}
|
||||
|
||||
// REST: POST /delivery-partners/deliveries/{id}/respond
|
||||
export type DeliveryAction = 'ACCEPT' | 'REJECT';
|
||||
|
||||
export interface RespondToOfferPayload {
|
||||
action: DeliveryAction;
|
||||
}
|
||||
|
||||
export interface RespondToOfferResponse {
|
||||
success: boolean;
|
||||
message: string;
|
||||
deliveryId?: string;
|
||||
}
|
||||
|
||||
// REST: GET /delivery-partners/deliveries/pending-offer
|
||||
export interface PendingOfferResponse {
|
||||
offer: DeliveryOffer | null;
|
||||
}
|
||||
@ -2,3 +2,5 @@ export * from './auth';
|
||||
export * from './onboard';
|
||||
export * from './kyc';
|
||||
export * from './dashboard';
|
||||
export * from './delivery';
|
||||
export * from './order';
|
||||
|
||||
112
app/interfaces/order.ts
Normal file
112
app/interfaces/order.ts
Normal file
@ -0,0 +1,112 @@
|
||||
export interface ActiveOrdersResponse {
|
||||
id: string;
|
||||
orderId: string;
|
||||
deliveryPartnerId: string;
|
||||
deliveryNumber: string;
|
||||
status: string;
|
||||
estimatedDistanceKm: string;
|
||||
actualDistanceKm: string;
|
||||
proofOfPickupKey: string;
|
||||
proofOfDeliveryKey: string;
|
||||
pickedUpAt: string;
|
||||
deliveredAt: string;
|
||||
order: {
|
||||
id: string;
|
||||
orderNumber: string;
|
||||
customerId: string;
|
||||
merchantId: string;
|
||||
couponCode: string;
|
||||
appliedPromotionId: string;
|
||||
status: string;
|
||||
orderType: string;
|
||||
paymentMethodId: string;
|
||||
paymentMethod: string;
|
||||
currency: string;
|
||||
subtotal: string;
|
||||
deliveryFee: string;
|
||||
platformFee: string;
|
||||
serviceFee: string;
|
||||
taxAmount: string;
|
||||
discountAmount: string;
|
||||
totalAmount: string;
|
||||
pickupAddress: {
|
||||
city: string;
|
||||
label: string;
|
||||
phone: string;
|
||||
state: string;
|
||||
landmark: string;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
postalCode: string;
|
||||
houseNumber: string;
|
||||
addressLine1: string;
|
||||
};
|
||||
dropAddress: {
|
||||
city: string;
|
||||
label: string;
|
||||
phone: string;
|
||||
state: string;
|
||||
landmark: string;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
postalCode: string;
|
||||
houseNumber: string;
|
||||
addressLine1: string;
|
||||
};
|
||||
// "metadata": {
|
||||
// "idempotencyKey": "idemp-key-12345",
|
||||
// "trackingHistory": {
|
||||
// "CONFIRMED": "2026-07-13T10:21:35.863Z"
|
||||
// },
|
||||
// "rejectedDriverIds": [
|
||||
// "e5f639a8-f0cf-4357-ab45-919ebc3012bd"
|
||||
// ]
|
||||
// },
|
||||
version: 1;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
deletedAt: string;
|
||||
customer: {
|
||||
id: string;
|
||||
userId: string;
|
||||
customerCode: string;
|
||||
createdAt: string;
|
||||
deletedAt: string;
|
||||
user: {
|
||||
id: string;
|
||||
name: string;
|
||||
phone: string;
|
||||
profileImage: string;
|
||||
};
|
||||
};
|
||||
// "merchant": {
|
||||
// "id": "f53b10bf-1d72-42e6-9d4e-b78032ad051b",
|
||||
// "name": "eererer",
|
||||
// "imageUrl": null,
|
||||
// "addresses": [
|
||||
// {
|
||||
// "id": "5476dd01-4b9c-4597-ad0f-1898ad333af6",
|
||||
// "mapAddress": "Bantra, Jaynagar - I, South 24 Parganas, West Bengal, India",
|
||||
// "latitude": "22.204311",
|
||||
// "longitude": "88.509444",
|
||||
// "city": "New York",
|
||||
// "landmark": "Near Central Park",
|
||||
// "phone": "+19999999999"
|
||||
// }
|
||||
// ]
|
||||
// },
|
||||
orderItems: OrderItem[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface OrderItem {
|
||||
id: string;
|
||||
orderId: string;
|
||||
productId: string;
|
||||
itemType: string;
|
||||
name: string;
|
||||
quantity: string;
|
||||
unitPrice: string;
|
||||
totalAmount: string;
|
||||
attributes: object;
|
||||
}
|
||||
@ -28,14 +28,15 @@ export type AppStackParamList = {
|
||||
[RouteNames.BottomTabs]: NavigatorScreenParams<BottomTabParamList>;
|
||||
[RouteNames.NewJobRequest]: undefined;
|
||||
[RouteNames.OrderAccepted]: undefined;
|
||||
[RouteNames.ArrivedAtStore]: undefined;
|
||||
[RouteNames.ArrivedAtStore]: { jobId: string };
|
||||
[RouteNames.ConfirmPickup]: undefined;
|
||||
[RouteNames.OrderPickedUp]: undefined;
|
||||
[RouteNames.LiveTracking]: undefined;
|
||||
[RouteNames.ArrivedAtCustomer]: undefined;
|
||||
[RouteNames.ArrivedAtCustomer]: { jobId: string };
|
||||
[RouteNames.DeliverOrder]: undefined;
|
||||
[RouteNames.DeliveryCompleted]: undefined;
|
||||
[RouteNames.Documents]: undefined;
|
||||
// [RouteNames.JobDetails]: { jobId: string };
|
||||
};
|
||||
|
||||
export type RootStackParamList = {
|
||||
|
||||
@ -18,10 +18,10 @@ const onBoardingStack: React.FC = () => {
|
||||
name={RouteNames.SetLocation}
|
||||
component={SetLocationScreen}
|
||||
/> */}
|
||||
{/* <Stack.Screen
|
||||
<Stack.Screen
|
||||
name={RouteNames.CompleteProfile}
|
||||
component={CompleteProfileScreen}
|
||||
/> */}
|
||||
/>
|
||||
<Stack.Screen name={RouteNames.Kyc} component={KycScreen} />
|
||||
<Stack.Screen
|
||||
name={RouteNames.OnboardingComplete}
|
||||
|
||||
@ -1,8 +1,9 @@
|
||||
import React from 'react';
|
||||
import React, { useRef, useEffect } from 'react';
|
||||
import {
|
||||
NavigationContainer,
|
||||
DefaultTheme,
|
||||
DarkTheme,
|
||||
NavigationContainerRef,
|
||||
} from '@react-navigation/native';
|
||||
import { createNativeStackNavigator } from '@react-navigation/native-stack';
|
||||
import { useColorScheme } from 'react-native';
|
||||
@ -11,6 +12,7 @@ import AuthStack from './authStack';
|
||||
import AppStack from './appStack';
|
||||
import onBoardingStack from './onBoardingStack';
|
||||
import { useAppSelector } from '@store';
|
||||
import { socketService } from '@services';
|
||||
|
||||
const Stack = createNativeStackNavigator<RootStackParamList>();
|
||||
|
||||
@ -23,8 +25,22 @@ const RootNavigator: React.FC = () => {
|
||||
state => state.auth.user?.status === 'ONBOARDING',
|
||||
);
|
||||
|
||||
const navigationRef = useRef<NavigationContainerRef<RootStackParamList>>(null);
|
||||
|
||||
// Inject the navigation ref into the socket service so it can navigate
|
||||
// to NewJobRequest from anywhere when a delivery_offer event arrives.
|
||||
useEffect(() => {
|
||||
if (navigationRef.current) {
|
||||
socketService.bootstrap(
|
||||
// store is already injected in App.tsx — pass null so it's not overwritten
|
||||
null as any,
|
||||
navigationRef.current,
|
||||
);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<NavigationContainer theme={theme}>
|
||||
<NavigationContainer ref={navigationRef} theme={theme}>
|
||||
<Stack.Navigator screenOptions={{ headerShown: false }}>
|
||||
{!accessToken ? (
|
||||
<Stack.Screen name="Auth" component={AuthStack} />
|
||||
|
||||
@ -15,7 +15,7 @@ const STORAGE_KEYS = {
|
||||
} as const;
|
||||
|
||||
// ─── Config ──────────────────────────────────────────────────────────────────
|
||||
const BASE_URL = 'https://720b-202-8-116-13.ngrok-free.app'; // TODO: replace with your actual base URL
|
||||
const BASE_URL = 'https://8415-202-8-116-13.ngrok-free.app'; // TODO: replace with your actual base URL
|
||||
|
||||
// ─── Token Helpers ───────────────────────────────────────────────────────────
|
||||
export const tokenManager = {
|
||||
|
||||
@ -1,2 +1,3 @@
|
||||
export * from './apiClient';
|
||||
export * from './locationService';
|
||||
export * from './socketService';
|
||||
|
||||
@ -7,6 +7,9 @@ import type { GeoPosition, GeoError } from 'react-native-geolocation-service';
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface LatLng {
|
||||
heading: number | null;
|
||||
speed: number | null;
|
||||
accuracy: number | null;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
}
|
||||
@ -26,6 +29,9 @@ const GOOGLE_MAPS_API_KEY = 'AIzaSyDKaKVyQCmSpHMXcMkNCccSXpILpuvwsVM';
|
||||
export const DEFAULT_LOCATION: LatLng = {
|
||||
latitude: 12.9352,
|
||||
longitude: 77.6245,
|
||||
heading: 0,
|
||||
speed: 0,
|
||||
accuracy: 0,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@ -103,10 +109,15 @@ const getPositionOnce = (highAccuracy: boolean): Promise<LatLng> =>
|
||||
new Promise<LatLng>((resolve, reject) => {
|
||||
Geolocation.getCurrentPosition(
|
||||
(position: GeoPosition) => {
|
||||
// console.log('position', position);
|
||||
resolve({
|
||||
latitude: position.coords.latitude,
|
||||
longitude: position.coords.longitude,
|
||||
heading: position.coords.heading,
|
||||
speed: position.coords.speed,
|
||||
accuracy: position.coords.accuracy,
|
||||
});
|
||||
// console.log('-----------------');
|
||||
},
|
||||
(error: GeoError) => {
|
||||
console.log(
|
||||
@ -231,8 +242,8 @@ export const getCurrentLocationWithAddress =
|
||||
const coords = await getCurrentPosition();
|
||||
const address = await reverseGeocode(coords);
|
||||
|
||||
console.log('coords', coords);
|
||||
console.log('address', address);
|
||||
// console.log('coords', coords);
|
||||
// console.log('address', address);
|
||||
|
||||
return { coords, address };
|
||||
};
|
||||
|
||||
145
app/services/socketService.ts
Normal file
145
app/services/socketService.ts
Normal file
@ -0,0 +1,145 @@
|
||||
import { io, Socket } from 'socket.io-client';
|
||||
import { Store } from '@reduxjs/toolkit';
|
||||
import { SocketEvents } from '@utils/constants';
|
||||
import { DeliveryOffer } from '@interfaces';
|
||||
import { setDeliveryOffer } from '@store/commonReducers/delivery';
|
||||
import { RouteNames } from '@utils/constants';
|
||||
|
||||
// Must match the REST API base URL (without trailing slash)
|
||||
const BASE_URL = 'https://8415-202-8-116-13.ngrok-free.app';
|
||||
|
||||
type Callback = (data: any) => void;
|
||||
|
||||
/**
|
||||
* Global singleton socket service powered by socket.io-client.
|
||||
*
|
||||
* Usage:
|
||||
* bootstrapSocketService(store, navRef) // call once in App.tsx
|
||||
* socketService.connect(token) // when user authenticates
|
||||
* socketService.disconnect() // on logout
|
||||
*/
|
||||
class SocketService {
|
||||
private socket: Socket | null = null;
|
||||
private store: Store | null = null;
|
||||
private navigationRef: any = null;
|
||||
|
||||
/**
|
||||
* Must be called once at app startup so the service can dispatch actions
|
||||
* and navigate globally when a `delivery_offer` event arrives.
|
||||
* Each parameter is only updated if a non-null value is provided, allowing
|
||||
* the store and navigationRef to be injected independently.
|
||||
*/
|
||||
bootstrap(store: Store | null, navigationRef: any) {
|
||||
if (store) { this.store = store; }
|
||||
if (navigationRef) { this.navigationRef = navigationRef; }
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the socket connection with JWT auth.
|
||||
*/
|
||||
connect(token: string) {
|
||||
if (this.socket?.connected) {
|
||||
console.log('[Socket] Already connected');
|
||||
return;
|
||||
}
|
||||
|
||||
this.socket = io(BASE_URL, {
|
||||
auth: { token },
|
||||
transports: ['websocket'],
|
||||
reconnection: true,
|
||||
reconnectionAttempts: Infinity,
|
||||
reconnectionDelay: 2000,
|
||||
});
|
||||
|
||||
this.socket.on('connect', () => {
|
||||
console.log('[Socket] Connected — id:', this.socket?.id);
|
||||
});
|
||||
|
||||
this.socket.on('connect_error', (err: Error) => {
|
||||
console.error('[Socket] Connection error:', err.message);
|
||||
});
|
||||
|
||||
this.socket.on('disconnect', (reason: string) => {
|
||||
console.log('[Socket] Disconnected:', reason);
|
||||
});
|
||||
|
||||
// ── Listen for delivery offers ─────────────────────────────────────
|
||||
this.socket.on(
|
||||
SocketEvents.DeliveryOffer,
|
||||
(data: DeliveryOffer) => {
|
||||
console.log('[Socket] delivery_offer received:', data);
|
||||
|
||||
if (this.store) {
|
||||
this.store.dispatch(setDeliveryOffer(data));
|
||||
}
|
||||
|
||||
// Navigate to NewJobRequest screen regardless of current screen
|
||||
if (this.navigationRef?.isReady()) {
|
||||
this.navigationRef.navigate(RouteNames.NewJobRequest);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gracefully close the socket connection.
|
||||
*/
|
||||
disconnect() {
|
||||
if (this.socket) {
|
||||
this.socket.removeAllListeners();
|
||||
this.socket.disconnect();
|
||||
this.socket = null;
|
||||
console.log('[Socket] Disconnected (manual)');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify the server that the partner is available for deliveries.
|
||||
*/
|
||||
goOnline() {
|
||||
this.socket?.emit(SocketEvents.GoOnline);
|
||||
console.log('[Socket] Emitted go_online');
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify the server that the partner is no longer available.
|
||||
*/
|
||||
goOffline() {
|
||||
this.socket?.emit(SocketEvents.GoOffline);
|
||||
console.log('[Socket] Emitted go_offline');
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a location update to the server.
|
||||
*/
|
||||
updateLocation(coords: { latitude: number; longitude: number }) {
|
||||
this.socket?.emit(SocketEvents.LocationUpdate, coords);
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a custom listener on the underlying socket.
|
||||
*/
|
||||
on(event: string, callback: Callback) {
|
||||
this.socket?.on(event, callback);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a custom listener from the underlying socket.
|
||||
*/
|
||||
off(event: string, callback?: Callback) {
|
||||
if (callback) {
|
||||
this.socket?.off(event, callback);
|
||||
} else {
|
||||
this.socket?.removeAllListeners(event);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the socket is currently connected.
|
||||
*/
|
||||
get connected(): boolean {
|
||||
return this.socket?.connected ?? false;
|
||||
}
|
||||
}
|
||||
|
||||
export const socketService = new SocketService();
|
||||
2
app/store/commonReducers/delivery/index.ts
Normal file
2
app/store/commonReducers/delivery/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export * from './thunk';
|
||||
export * from './reducer';
|
||||
61
app/store/commonReducers/delivery/reducer.ts
Normal file
61
app/store/commonReducers/delivery/reducer.ts
Normal file
@ -0,0 +1,61 @@
|
||||
import { createReducer, createAction } from '@reduxjs/toolkit';
|
||||
import { DeliveryOffer } from '@interfaces';
|
||||
import { respondToOfferThunk, checkPendingOfferThunk } from './thunk';
|
||||
|
||||
export interface DeliveryState {
|
||||
currentOffer: DeliveryOffer | null;
|
||||
respondLoading: boolean;
|
||||
respondError: string | null;
|
||||
pendingOfferLoading: boolean;
|
||||
}
|
||||
|
||||
const initialState: DeliveryState = {
|
||||
currentOffer: null,
|
||||
respondLoading: false,
|
||||
respondError: null,
|
||||
pendingOfferLoading: false,
|
||||
};
|
||||
|
||||
/** Dispatched by socketService when a `delivery_offer` event is received. */
|
||||
export const setDeliveryOffer = createAction<DeliveryOffer>(
|
||||
'delivery/setDeliveryOffer',
|
||||
);
|
||||
|
||||
/** Dispatched after accept/reject to clear the current offer from state. */
|
||||
export const clearDeliveryOffer = createAction('delivery/clearDeliveryOffer');
|
||||
|
||||
export const deliveryReducer = createReducer(initialState, builder => {
|
||||
builder
|
||||
// ── Sync actions ──────────────────────────────────────────────────────
|
||||
.addCase(setDeliveryOffer, (state, action) => {
|
||||
state.currentOffer = action.payload;
|
||||
})
|
||||
.addCase(clearDeliveryOffer, state => {
|
||||
state.currentOffer = null;
|
||||
state.respondError = null;
|
||||
})
|
||||
// ── respondToOfferThunk ───────────────────────────────────────────────
|
||||
.addCase(respondToOfferThunk.pending, state => {
|
||||
state.respondLoading = true;
|
||||
state.respondError = null;
|
||||
})
|
||||
.addCase(respondToOfferThunk.fulfilled, state => {
|
||||
state.respondLoading = false;
|
||||
state.currentOffer = null;
|
||||
})
|
||||
.addCase(respondToOfferThunk.rejected, (state, action) => {
|
||||
state.respondLoading = false;
|
||||
state.respondError = action.payload as string;
|
||||
})
|
||||
// ── checkPendingOfferThunk ────────────────────────────────────────────
|
||||
.addCase(checkPendingOfferThunk.pending, state => {
|
||||
state.pendingOfferLoading = true;
|
||||
})
|
||||
.addCase(checkPendingOfferThunk.fulfilled, (state, action) => {
|
||||
state.pendingOfferLoading = false;
|
||||
state.currentOffer = action.payload.offer;
|
||||
})
|
||||
.addCase(checkPendingOfferThunk.rejected, state => {
|
||||
state.pendingOfferLoading = false;
|
||||
});
|
||||
});
|
||||
42
app/store/commonReducers/delivery/thunk.ts
Normal file
42
app/store/commonReducers/delivery/thunk.ts
Normal file
@ -0,0 +1,42 @@
|
||||
import { createAsyncThunk } from '@reduxjs/toolkit';
|
||||
import {
|
||||
DeliveryAction,
|
||||
RespondToOfferResponse,
|
||||
PendingOfferResponse,
|
||||
} from '@interfaces';
|
||||
import * as deliveryApi from '../../../api/deliveryApi';
|
||||
|
||||
/**
|
||||
* Respond to a delivery offer (ACCEPT / REJECT).
|
||||
*/
|
||||
export const respondToOfferThunk = createAsyncThunk<
|
||||
RespondToOfferResponse,
|
||||
{ deliveryId: string; action: DeliveryAction }
|
||||
>(
|
||||
'delivery/respondToOffer',
|
||||
async ({ deliveryId, action }, { rejectWithValue }) => {
|
||||
try {
|
||||
return await deliveryApi.respondToOffer(deliveryId, action);
|
||||
} catch (error: any) {
|
||||
return rejectWithValue(
|
||||
error.message || 'Failed to respond to delivery offer',
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Check for any pending delivery offer (e.g. on app launch / reconnect).
|
||||
*/
|
||||
export const checkPendingOfferThunk = createAsyncThunk<PendingOfferResponse>(
|
||||
'delivery/checkPendingOffer',
|
||||
async (_, { rejectWithValue }) => {
|
||||
try {
|
||||
return await deliveryApi.getPendingOffer();
|
||||
} catch (error: any) {
|
||||
return rejectWithValue(
|
||||
error.message || 'Failed to check pending offers',
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
@ -1,3 +1,4 @@
|
||||
export * from './auth';
|
||||
export * from './earnings';
|
||||
export * from './job';
|
||||
export * from './delivery';
|
||||
|
||||
@ -2,20 +2,24 @@ import { combineReducers } from '@reduxjs/toolkit';
|
||||
import { authReducer } from './commonReducers/auth';
|
||||
import { earningsReducer } from './commonReducers/earnings';
|
||||
import { jobReducer } from './commonReducers/job';
|
||||
import { deliveryReducer } from './commonReducers/delivery';
|
||||
import setLocationReducer from '@features/screens/setLocationScreen/reducer';
|
||||
import { completeProfileReducer, kycReducer } from '@features/screens';
|
||||
import { onBoardingCompleteReducer } from '@features/screens/onboardingCompleteScreen';
|
||||
import { dashBoardReducer } from '@features/screens/dashboardScreen';
|
||||
import { activeDeliveriesReducer } from '@features/screens/jobsScreen';
|
||||
|
||||
const rootReducer = combineReducers({
|
||||
auth: authReducer,
|
||||
earnings: earningsReducer,
|
||||
job: jobReducer,
|
||||
delivery: deliveryReducer,
|
||||
setLocation: setLocationReducer,
|
||||
onboard: completeProfileReducer,
|
||||
kyc: kycReducer,
|
||||
onboardingComplete: onBoardingCompleteReducer,
|
||||
dashboard: dashBoardReducer,
|
||||
deliveries: activeDeliveriesReducer,
|
||||
});
|
||||
|
||||
export type RootState = ReturnType<typeof rootReducer>;
|
||||
|
||||
@ -36,8 +36,8 @@ export interface JobOffer {
|
||||
dropName: string;
|
||||
dropAddress: string;
|
||||
dropCoordinates: Coordinates;
|
||||
amount: number;
|
||||
distance: number;
|
||||
amount: string;
|
||||
distance: string;
|
||||
estimatedTime: number;
|
||||
paymentType: 'online' | 'cod';
|
||||
itemCount: number;
|
||||
|
||||
@ -45,6 +45,7 @@ export enum JobStatus {
|
||||
|
||||
export enum SocketEvents {
|
||||
NewJob = 'new_job',
|
||||
DeliveryOffer = 'delivery_offer',
|
||||
JobCancelled = 'job_cancelled',
|
||||
LocationUpdate = 'location_update',
|
||||
GoOnline = 'go_online',
|
||||
|
||||
511
package-lock.json
generated
511
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -34,7 +34,8 @@
|
||||
"react-redux": "^9.3.0",
|
||||
"reactotron-react-native": "^5.2.0",
|
||||
"redux-persist": "^6.0.0",
|
||||
"redux-thunk": "^3.1.0"
|
||||
"redux-thunk": "^3.1.0",
|
||||
"socket.io-client": "^4.8.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.25.2",
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user