feat: implement order status tracking component and wallet management infrastructure

This commit is contained in:
Atanu Das 2026-07-23 16:06:13 +05:30
parent 8c443d76eb
commit eecbf9484c
9 changed files with 371 additions and 44 deletions

View File

@ -8,3 +8,4 @@ export * from './customerDetailsApi';
export * from './orderApi'; export * from './orderApi';
export * from './offerApi'; export * from './offerApi';
export * from './reviewApi'; export * from './reviewApi';
export * from './walletApi';

15
app/api/walletApi.ts Normal file
View File

@ -0,0 +1,15 @@
import { WalletTopUpPaymentSessionResponse } from "@interfaces";
import { apiClient } from "@services";
export interface TopupPayload {
amount: number,
paymentMethod: string
}
export const initializeWalletTopUpApi = async (
payload: TopupPayload): Promise<WalletTopUpPaymentSessionResponse> => {
return await apiClient.post<WalletTopUpPaymentSessionResponse>(
'/wallets/topup',
payload
);
};

View File

@ -207,8 +207,7 @@ export const OrderStatusTimeline: React.FC<OrderStatusTimelineProps> = ({
]; ];
const showDriver = [ const showDriver = [
'OUT_FOR_DELIVERY', 'OUT_FOR_DELIVERY'
'DELIVERED',
].includes(currentStatus); ].includes(currentStatus);
const currentIndex = STATUS_FLOW.indexOf(currentStatus); const currentIndex = STATUS_FLOW.indexOf(currentStatus);

View File

@ -0,0 +1,102 @@
import { useState, useCallback } from 'react';
import { Alert } from 'react-native';
import { useStripe } from '@stripe/stripe-react-native';
import { PlaceOrderResponse, WalletTopUpPaymentSessionResponse } from '@interfaces';
import { verifyPaymentApi } from '@api';
/**
* Custom hook that encapsulates the entire Stripe card payment flow:
* 1. Extract session info from the place-order response
* 2. Initialize the Stripe Payment Sheet
* 3. Present the Payment Sheet to the user
* 4. Verify the payment on the backend via /payments/process
*
* Returns:
* - processCardPayment: async function to run the full flow
* - isPaymentProcessing: loading flag for UI
*/
export const useTopUp = () => {
const { initPaymentSheet, presentPaymentSheet } = useStripe();
const [isPaymentProcessing, setIsPaymentProcessing] = useState(false);
/**
* Runs the complete Stripe card payment flow.
* @param response the PlaceOrderResponse from the backend (contains checkoutSessions)
* @returns `true` if payment was verified successfully, `false` otherwise
*/
const topUpCardPayment = useCallback(
async (response: WalletTopUpPaymentSessionResponse): Promise<boolean> => {
try {
setIsPaymentProcessing(true);
// ── 1. Extract session details ─────────────────────────────────────
// const session = response.checkoutSessions?.[0];
// console.log('session', session);
const clientSecret = response?.gatewayToken;
// console.log('clientSecret', clientSecret);
const gatewayOrderId = response?.gatewayOrderId;
// console.log('gatewayOrderId', gatewayOrderId);
if (!clientSecret) {
Alert.alert('Error', 'Payment session token not found.');
return false;
}
// ── 2. Initialize Payment Sheet ────────────────────────────────────
const { error: initError } = await initPaymentSheet({
paymentIntentClientSecret: clientSecret,
merchantDisplayName: 'SG Delivery',
});
if (initError) {
Alert.alert('Error', `Stripe init failed: ${initError.message}`);
return false;
}
// ── 3. Present Payment Sheet ───────────────────────────────────────
const { error: presentError } = await presentPaymentSheet();
if (presentError) {
if (presentError.code === 'Canceled') {
Alert.alert(
'Payment Cancelled',
'You cancelled the payment process.',
);
} else {
console.log(presentError.message);
Alert.alert('Payment Error', presentError.message);
}
return false;
}
// ── 4. Verify payment on backend ───────────────────────────────────
const verifyRes = await verifyPaymentApi({
gatewayOrderId: gatewayOrderId || '',
gatewayPaymentId: gatewayOrderId || '',
gatewaySignature: 'stripe_signature_verified',
});
if (verifyRes.success) {
return true;
} else {
Alert.alert(
'Verification Failed',
verifyRes.message || 'Unable to confirm payment.',
);
return false;
}
} catch (error: any) {
Alert.alert(
'Payment Error',
error?.message || 'Something went wrong during payment.',
);
return false;
} finally {
setIsPaymentProcessing(false);
}
},
[initPaymentSheet, presentPaymentSheet],
);
return { topUpCardPayment, isPaymentProcessing };
};

View File

@ -1,17 +1,19 @@
import { WalletTransaction } from '@interfaces'; import { WalletTopUpPaymentSessionResponse, WalletTransaction } from '@interfaces';
import { createReducer } from '@reduxjs/toolkit'; import { createReducer } from '@reduxjs/toolkit';
import { getAllWalletTransactions } from './thunk'; import { getAllWalletTransactions, initializeWalletTopUpThunk } from './thunk';
export interface walletState { export interface walletState {
walletTransactions: WalletTransaction[]; walletTransactions: WalletTransaction[];
loading: boolean; loading: boolean;
error: string | null; error: string | null;
walletTopUpPaymentSessionResponse: WalletTopUpPaymentSessionResponse | null;
} }
const initialState: walletState = { const initialState: walletState = {
walletTransactions: [], walletTransactions: [],
loading: false, loading: false,
error: null, error: null,
walletTopUpPaymentSessionResponse: null,
}; };
export const walletReducer = createReducer(initialState, builder => { export const walletReducer = createReducer(initialState, builder => {
@ -27,4 +29,16 @@ export const walletReducer = createReducer(initialState, builder => {
state.loading = false; state.loading = false;
state.error = action.payload as string | null; state.error = action.payload as string | null;
}); });
builder.addCase(initializeWalletTopUpThunk.pending, state => {
state.loading = true;
state.error = null;
});
builder.addCase(initializeWalletTopUpThunk.fulfilled, (state, action) => {
state.loading = false;
state.walletTopUpPaymentSessionResponse = action.payload;
});
builder.addCase(initializeWalletTopUpThunk.rejected, (state, action) => {
state.loading = false;
state.error = action.payload as string | null;
});
}); });

View File

@ -1,4 +1,4 @@
import { getWalletTransactions } from '@api'; import { getWalletTransactions, initializeWalletTopUpApi, TopupPayload } from '@api';
import { createAsyncThunk } from '@reduxjs/toolkit'; import { createAsyncThunk } from '@reduxjs/toolkit';
export const getAllWalletTransactions = createAsyncThunk( export const getAllWalletTransactions = createAsyncThunk(
@ -12,3 +12,15 @@ export const getAllWalletTransactions = createAsyncThunk(
} }
}, },
); );
export const initializeWalletTopUpThunk = createAsyncThunk(
'wallet/initializeWalletTopUp',
async (payload: TopupPayload, { rejectWithValue }) => {
try {
const response = await initializeWalletTopUpApi(payload);
return response;
} catch (error: any) {
return rejectWithValue(error.response.data);
}
},
);

View File

@ -125,4 +125,97 @@ export const getStyles = (theme: any) =>
color: '#888', color: '#888',
fontSize: 16, fontSize: 16,
}, },
modalOverlay: {
flex: 1,
justifyContent: 'flex-end',
},
modalBackdrop: {
...StyleSheet.absoluteFill,
backgroundColor: 'rgba(0,0,0,0.4)',
},
modalCard: {
backgroundColor: '#FFF',
borderTopLeftRadius: 24,
borderTopRightRadius: 24,
padding: 24,
paddingBottom: 32,
},
modalTitle: {
fontSize: 20,
fontWeight: '700',
color: '#222',
},
modalSubtitle: {
fontSize: 13,
color: '#888',
marginTop: 4,
marginBottom: 20,
},
amountInputWrapper: {
flexDirection: 'row',
alignItems: 'center',
borderWidth: 1,
borderColor: '#E0E0E0',
borderRadius: 12,
paddingHorizontal: 16,
height: 56,
},
rupeeSymbol: {
fontSize: 22,
fontWeight: '700',
color: '#222',
marginRight: 8,
},
amountInput: {
flex: 1,
fontSize: 22,
fontWeight: '700',
color: '#222',
padding: 0,
},
modalError: {
color: '#F04438',
fontSize: 13,
marginTop: 8,
},
topUpButton: {
marginTop: 24,
backgroundColor: '#2F80ED',
height: 52,
borderRadius: 12,
justifyContent: 'center',
alignItems: 'center',
},
topUpButtonDisabled: {
opacity: 0.6,
},
topUpButtonText: {
color: '#FFF',
fontWeight: '700',
fontSize: 16,
},
modalCancelButton: {
marginTop: 12,
height: 48,
justifyContent: 'center',
alignItems: 'center',
},
modalCancelText: {
color: '#888',
fontWeight: '600',
fontSize: 15,
},
}); });

View File

@ -1,10 +1,15 @@
import React, { useCallback, useEffect } from 'react'; import React, { useCallback, useState } from 'react';
import { import {
View, View,
Text, Text,
SafeAreaView, SafeAreaView,
FlatList, FlatList,
TouchableOpacity, TouchableOpacity,
Modal,
TextInput,
KeyboardAvoidingView,
Platform,
Pressable,
} from 'react-native'; } from 'react-native';
// import Icon from 'react-native-vector-icons/MaterialCommunityIcons'; // import Icon from 'react-native-vector-icons/MaterialCommunityIcons';
import { Header } from '@components'; import { Header } from '@components';
@ -12,42 +17,12 @@ import { useAppTheme } from '@theme';
import { getStyles } from './walletScreen.styles'; import { getStyles } from './walletScreen.styles';
import { WalletTransaction } from '@interfaces'; import { WalletTransaction } from '@interfaces';
import { useAppDispatch, useAppSelector } from '@store'; import { useAppDispatch, useAppSelector } from '@store';
import { getAllWalletTransactions } from './thunk'; import { getAllWalletTransactions, initializeWalletTopUpThunk } from './thunk';
import { useFocusEffect } from '@react-navigation/native'; import { useFocusEffect } from '@react-navigation/native';
import { getWalletBalanceThunk } from '../accountScreen'; import { getWalletBalanceThunk } from '../accountScreen';
import { useStripePayment } from '../checkoutPaymentScreen/hooks/useStripePayment';
// const balance = 17496.05; import { useTopUp } from './hooks/useTopUp';
// import { addMoneyThunk } from './thunk'; // wire this up to your actual top-up API
// const transactions: WalletTransaction[] = [
// {
// id: '1',
// walletId: '',
// transactionReference: 'TXN-1784543790637-2387',
// direction: 'OUT',
// amount: '1408.95',
// balanceBefore: '18905',
// balanceAfter: '17496.05',
// type: 'PAYMENT',
// referenceId: null,
// description: null,
// idempotencyKey: null,
// createdAt: '2026-07-20T10:36:30.639Z',
// },
// {
// id: '2',
// walletId: '',
// transactionReference: 'TXN-1784199810512-8502',
// direction: 'OUT',
// amount: '1095',
// balanceBefore: '20000',
// balanceAfter: '18905',
// type: 'PAYMENT',
// referenceId: null,
// description: null,
// idempotencyKey: null,
// createdAt: '2026-07-16T11:03:30.515Z',
// },
// ];
const WalletScreen = () => { const WalletScreen = () => {
const { colors } = useAppTheme(); const { colors } = useAppTheme();
@ -57,6 +32,12 @@ const WalletScreen = () => {
const { walletTransactions } = useAppSelector(state => state.wallet); const { walletTransactions } = useAppSelector(state => state.wallet);
const { wallet } = useAppSelector(state => state.account); const { wallet } = useAppSelector(state => state.account);
const [isModalVisible, setIsModalVisible] = useState(false);
const [amount, setAmount] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState('');
const { topUpCardPayment, isPaymentProcessing } = useTopUp();
useFocusEffect( useFocusEffect(
useCallback(() => { useCallback(() => {
dispatch(getAllWalletTransactions()); dispatch(getAllWalletTransactions());
@ -64,6 +45,50 @@ const WalletScreen = () => {
}, []), }, []),
); );
const openModal = () => {
setAmount('');
setError('');
setIsModalVisible(true);
};
const closeModal = () => {
if (isSubmitting) return;
setIsModalVisible(false);
};
const handleAmountChange = (text: string) => {
// allow only digits and a single decimal point
const sanitized = text.replace(/[^0-9.]/g, '');
setAmount(sanitized);
if (error) setError('');
};
const handleTopUp = async () => {
const numericAmount = parseFloat(amount);
if (!amount || isNaN(numericAmount) || numericAmount <= 0) {
setError('Please enter a valid amount');
return;
}
try {
setIsSubmitting(true);
const payload = {
amount: numericAmount,
paymentMethod: 'CARD'
}
const response = await dispatch(initializeWalletTopUpThunk(payload)).unwrap();
const success = await topUpCardPayment(response);
setIsSubmitting(false);
setIsModalVisible(false);
setAmount('');
} catch (e) {
setIsSubmitting(false);
setError('Something went wrong. Please try again.');
}
};
const renderItem = ({ item }: { item: WalletTransaction }) => { const renderItem = ({ item }: { item: WalletTransaction }) => {
const isCredit = item.direction === 'IN'; const isCredit = item.direction === 'IN';
@ -113,7 +138,7 @@ const WalletScreen = () => {
}; };
return ( return (
<SafeAreaView style={styles.container}> <KeyboardAvoidingView style={styles.container}>
<Header title="Wallet" /> <Header title="Wallet" />
<View style={styles.balanceCard}> <View style={styles.balanceCard}>
@ -121,7 +146,7 @@ const WalletScreen = () => {
<Text style={styles.balance}>{wallet?.balance.toFixed(2)}</Text> <Text style={styles.balance}>{wallet?.balance.toFixed(2)}</Text>
<TouchableOpacity style={styles.addMoneyButton}> <TouchableOpacity style={styles.addMoneyButton} onPress={openModal}>
{/* <Icon name="plus" color="#FFF" size={20} /> */} {/* <Icon name="plus" color="#FFF" size={20} /> */}
<Text style={styles.addMoneyText}>Add Money</Text> <Text style={styles.addMoneyText}>Add Money</Text>
</TouchableOpacity> </TouchableOpacity>
@ -142,7 +167,64 @@ const WalletScreen = () => {
</View> </View>
} }
/> />
</SafeAreaView>
<Modal
visible={isModalVisible}
transparent
animationType="fade"
onRequestClose={closeModal}
>
<KeyboardAvoidingView
style={styles.modalOverlay}
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
>
<Pressable style={styles.modalBackdrop} onPress={closeModal} />
<View style={styles.modalCard}>
<Text style={styles.modalTitle}>Add Money</Text>
<Text style={styles.modalSubtitle}>
Enter an amount to top up your wallet
</Text>
<View style={styles.amountInputWrapper}>
<Text style={styles.rupeeSymbol}></Text>
<TextInput
style={styles.amountInput}
keyboardType="decimal-pad"
placeholder="0.00"
placeholderTextColor="#B0B0B0"
value={amount}
onChangeText={handleAmountChange}
autoFocus
/>
</View>
{error ? <Text style={styles.modalError}>{error}</Text> : null}
<TouchableOpacity
style={[
styles.topUpButton,
isSubmitting && styles.topUpButtonDisabled,
]}
onPress={handleTopUp}
disabled={isSubmitting}
>
<Text style={styles.topUpButtonText}>
{isSubmitting ? 'Processing...' : 'Add Top Up'}
</Text>
</TouchableOpacity>
<TouchableOpacity
style={styles.modalCancelButton}
onPress={closeModal}
disabled={isSubmitting}
>
<Text style={styles.modalCancelText}>Cancel</Text>
</TouchableOpacity>
</View>
</KeyboardAvoidingView>
</Modal>
</KeyboardAvoidingView>
); );
}; };

View File

@ -22,3 +22,12 @@ export type TransactionType =
| 'WITHDRAWAL' | 'WITHDRAWAL'
| 'TRANSFER' | 'TRANSFER'
| 'ADJUSTMENT'; | 'ADJUSTMENT';
export interface WalletTopUpPaymentSessionResponse {
message: string;
paymentId: string;
paymentReference: string;
gatewayOrderId: string;
gatewayToken: string;
amount: number;
}