Add Select Amount and Pay EMI screens
parent
9d5869c3c2
commit
d12efb4fd5
|
|
@ -39,7 +39,6 @@ export default function TabLayout() {
|
|||
initSocket();
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch user details", error);
|
||||
logout();
|
||||
showSnackbar("Failed to fetch user details", "error");
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -18,9 +18,10 @@ import CustomerCareIcon from "../../assets/icons/customer-care.svg";
|
|||
import api from "@/services/axiosClient";
|
||||
import PaymentHistoryCard from "@/components/Payments/PaymentHistoryCard";
|
||||
import { BASE_URL } from "@/constants/config";
|
||||
import { useDispatch } from "react-redux";
|
||||
import { setDueAmount, setMyPlan } from "@/store/paymentSlice";
|
||||
|
||||
// Type definitions
|
||||
interface MyPlan {
|
||||
export interface MyPlan {
|
||||
no_of_emi: number;
|
||||
total_amount: number;
|
||||
down_payment: number;
|
||||
|
|
@ -71,8 +72,7 @@ interface PaymentHistoryResponse {
|
|||
|
||||
const formatPaymentDate = (dateString: string) => {
|
||||
try {
|
||||
// Parse the formatted date from API (e.g., "7 Aug 2025, 5:23:58 pm")
|
||||
return dateString.split(",")[0]; // Extract just the date part
|
||||
return dateString.split(",")[0];
|
||||
} catch {
|
||||
return dateString;
|
||||
}
|
||||
|
|
@ -105,7 +105,7 @@ export default function PaymentsTabScreen() {
|
|||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isSupportModalVisible, setIsSupportModalVisible] = useState(false);
|
||||
const [isEndReached, setIsEndReached] = useState(false);
|
||||
|
||||
const dispatch = useDispatch();
|
||||
//payment history states
|
||||
const [paymentHistory, setPaymentHistory] = useState<PaymentHistoryItem[]>(
|
||||
[]
|
||||
|
|
@ -138,7 +138,12 @@ export default function PaymentsTabScreen() {
|
|||
const result: EmiResponse = response.data;
|
||||
|
||||
if (result.success && result.data.length > 0) {
|
||||
setEmiDetails(result.data[0]);
|
||||
const details = result.data[0];
|
||||
|
||||
setEmiDetails(details);
|
||||
|
||||
dispatch(setDueAmount(details.due_amount));
|
||||
dispatch(setMyPlan(details.myPlain));
|
||||
} else {
|
||||
showSnackbar("No EMI details found", "error");
|
||||
}
|
||||
|
|
@ -357,16 +362,20 @@ export default function PaymentsTabScreen() {
|
|||
</View>
|
||||
)}
|
||||
|
||||
{/* Divider */}
|
||||
<View style={styles.divider} />
|
||||
|
||||
{/* Action Buttons */}
|
||||
<View style={styles.buttonContainer}>
|
||||
<TouchableOpacity style={styles.primaryButton}>
|
||||
<TouchableOpacity
|
||||
style={styles.primaryButton}
|
||||
onPress={() => router.push("/payments/selectAmount")}
|
||||
>
|
||||
<Text style={styles.primaryButtonText}>Pay EMI</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity style={styles.tertiaryButton}>
|
||||
<TouchableOpacity
|
||||
style={styles.tertiaryButton}
|
||||
onPress={() => router.push("/payments/myPlan")}
|
||||
>
|
||||
<Text style={styles.tertiaryButtonText}>View Plan</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
|
@ -445,8 +454,14 @@ export default function PaymentsTabScreen() {
|
|||
);
|
||||
}
|
||||
|
||||
const StatusBadge = ({ label, type }: { label: string; type: string }) => {
|
||||
if (!label) return null;
|
||||
const StatusBadge = ({
|
||||
label,
|
||||
type,
|
||||
}: {
|
||||
label: string | undefined;
|
||||
type: string | undefined;
|
||||
}) => {
|
||||
if (!label || !type) return "--";
|
||||
const getBadgeStyle = (type: string) => {
|
||||
switch (type) {
|
||||
case "pending":
|
||||
|
|
|
|||
|
|
@ -0,0 +1,277 @@
|
|||
import Header from "@/components/common/Header";
|
||||
import ProgressCard from "@/components/common/ProgressCard";
|
||||
import { RootState } from "@/store/rootReducer";
|
||||
import React from "react";
|
||||
import { View, Text, StyleSheet, TouchableOpacity } from "react-native";
|
||||
import { useSelector } from "react-redux";
|
||||
|
||||
interface MyPlan {
|
||||
planType?: string;
|
||||
totalCost?: number;
|
||||
downPayment?: number;
|
||||
totalEmi?: number;
|
||||
monthlyEmi?: number;
|
||||
installmentsPaid?: number;
|
||||
totalInstallments?: number;
|
||||
emiPaidTillNow?: number;
|
||||
totalAmountDue?: number;
|
||||
}
|
||||
|
||||
const MyPlanScreen: React.FC = () => {
|
||||
const myPlan = useSelector((state: RootState) => state.payments.myPlan);
|
||||
const dueAmount = useSelector(
|
||||
(state: RootState) => state.payments.due_amount
|
||||
);
|
||||
|
||||
// Helper function to format currency
|
||||
const formatCurrency = (amount?: number | null): string => {
|
||||
if (amount == null) return "---";
|
||||
return `₹${amount.toLocaleString("en-IN")}`;
|
||||
};
|
||||
|
||||
// Helper function to display value or fallback
|
||||
const displayValue = (value?: string | number | null): string => {
|
||||
if (value == null || value === "") return "---";
|
||||
return value.toString();
|
||||
};
|
||||
|
||||
// Calculate progress percentage for EMI progress bar
|
||||
const getProgressPercentage = (
|
||||
firstValue: number,
|
||||
secondValue: number
|
||||
): number => {
|
||||
if (!firstValue || !secondValue) return 0;
|
||||
return (firstValue / secondValue) * 100;
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<Header title="My Plan" showBackButton={true} />
|
||||
|
||||
<View style={styles.contentFrame}>
|
||||
{/* Plan Details Card */}
|
||||
<View style={styles.card}>
|
||||
<Text style={styles.cardTitle}>Plan Details</Text>
|
||||
<View style={styles.divider} />
|
||||
|
||||
<View style={styles.content}>
|
||||
{/* Plan Type Row */}
|
||||
<View style={styles.row}>
|
||||
<Text style={styles.label}>Plan Type</Text>
|
||||
<Text style={styles.value}>
|
||||
{displayValue(myPlan?.no_of_emi)}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* Total Cost Row */}
|
||||
<View style={styles.row}>
|
||||
<Text style={styles.label}>Total Cost</Text>
|
||||
<Text style={styles.value}>
|
||||
{formatCurrency(myPlan?.total_amount)}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* Down Payment Row */}
|
||||
<View style={styles.row}>
|
||||
<Text style={styles.label}>Down Payment</Text>
|
||||
<Text style={styles.value}>
|
||||
{formatCurrency(myPlan?.down_payment)}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* Total EMI Row */}
|
||||
<View style={styles.row}>
|
||||
<Text style={styles.label}>Total EMI</Text>
|
||||
<Text style={styles.value}>
|
||||
{formatCurrency(myPlan?.total_emi)}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Total Amount Due Card */}
|
||||
<View style={styles.card}>
|
||||
<Text style={styles.centerLabel}>Total Amount Due</Text>
|
||||
<View style={styles.amountContainer}>
|
||||
<Text style={styles.dueAmount}>{formatCurrency(dueAmount)}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.twoColumnContainer}>
|
||||
{/* Monthly EMI Card */}
|
||||
<View style={styles.halfCard}>
|
||||
<Text style={styles.label}>Monthly EMI</Text>
|
||||
<Text style={styles.value}>
|
||||
{formatCurrency(myPlan?.total_emi)}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.halfCard}>
|
||||
<Text style={styles.label}>Installments Paid</Text>
|
||||
<View style={styles.installmentRow}>
|
||||
<Text style={styles.value}>
|
||||
{displayValue(myPlan?.installment_paid)}
|
||||
</Text>
|
||||
<Text style={styles.installmentTotal}>
|
||||
/ {displayValue(myPlan?.no_of_emi)}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<ProgressCard
|
||||
title="EMI Paid Till Now"
|
||||
firstText={formatCurrency(myPlan?.current_amount)}
|
||||
secondText={formatCurrency(myPlan?.total_emi)}
|
||||
percentage={getProgressPercentage(
|
||||
myPlan?.current_amount || 0,
|
||||
myPlan?.total_emi || 0
|
||||
)}
|
||||
/>
|
||||
|
||||
<TouchableOpacity style={styles.payButton}>
|
||||
<Text style={styles.payButtonText}>Pay EMI</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: "#F3F5F8",
|
||||
paddingTop: 16,
|
||||
},
|
||||
header: {
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 16,
|
||||
backgroundColor: "#F3F5F8",
|
||||
},
|
||||
headerTitle: {
|
||||
fontSize: 18,
|
||||
fontWeight: "600",
|
||||
color: "#252A34",
|
||||
},
|
||||
contentFrame: {
|
||||
flex: 1,
|
||||
paddingHorizontal: 16,
|
||||
paddingBottom: 16,
|
||||
gap: 16,
|
||||
},
|
||||
card: {
|
||||
backgroundColor: "#FCFCFC",
|
||||
borderRadius: 8,
|
||||
padding: 12,
|
||||
gap: 12,
|
||||
},
|
||||
cardTitle: {
|
||||
fontSize: 14,
|
||||
fontWeight: "600",
|
||||
color: "#252A34",
|
||||
height: 20,
|
||||
},
|
||||
divider: {
|
||||
height: 1,
|
||||
backgroundColor: "#E5E9F0",
|
||||
},
|
||||
content: {
|
||||
gap: 8,
|
||||
},
|
||||
row: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
height: 20,
|
||||
},
|
||||
label: {
|
||||
fontSize: 14,
|
||||
fontWeight: "400",
|
||||
color: "#252A34",
|
||||
},
|
||||
value: {
|
||||
fontSize: 14,
|
||||
fontWeight: "600",
|
||||
color: "#252A34",
|
||||
},
|
||||
centerLabel: {
|
||||
fontSize: 14,
|
||||
fontWeight: "400",
|
||||
color: "#252A34",
|
||||
textAlign: "center",
|
||||
height: 20,
|
||||
},
|
||||
amountContainer: {
|
||||
height: 28,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
},
|
||||
dueAmount: {
|
||||
fontSize: 20,
|
||||
fontWeight: "600",
|
||||
color: "#252A34",
|
||||
textAlign: "center",
|
||||
},
|
||||
twoColumnContainer: {
|
||||
flexDirection: "row",
|
||||
gap: 16,
|
||||
},
|
||||
halfCard: {
|
||||
flex: 1,
|
||||
backgroundColor: "#FCFCFC",
|
||||
borderRadius: 8,
|
||||
padding: 12,
|
||||
gap: 8,
|
||||
height: 72,
|
||||
},
|
||||
installmentRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "flex-end",
|
||||
gap: 4,
|
||||
},
|
||||
installmentTotal: {
|
||||
fontSize: 14,
|
||||
fontWeight: "500",
|
||||
color: "#565F70",
|
||||
},
|
||||
progressAmountRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "flex-end",
|
||||
gap: 4,
|
||||
},
|
||||
progressTotal: {
|
||||
fontSize: 14,
|
||||
fontWeight: "500",
|
||||
color: "#565F70",
|
||||
},
|
||||
progressBarContainer: {
|
||||
width: "100%",
|
||||
height: 8,
|
||||
},
|
||||
progressBarBackground: {
|
||||
width: "100%",
|
||||
height: 8,
|
||||
backgroundColor: "#E5EAF0",
|
||||
borderRadius: 10,
|
||||
overflow: "hidden",
|
||||
},
|
||||
progressBarFill: {
|
||||
height: "100%",
|
||||
backgroundColor: "#4CAF50",
|
||||
borderRadius: 10,
|
||||
},
|
||||
payButton: {
|
||||
backgroundColor: "#4CAF50",
|
||||
borderRadius: 8,
|
||||
paddingVertical: 16,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
payButtonText: {
|
||||
fontSize: 16,
|
||||
fontWeight: "600",
|
||||
color: "#FFFFFF",
|
||||
},
|
||||
});
|
||||
|
||||
export default MyPlanScreen;
|
||||
|
|
@ -0,0 +1,529 @@
|
|||
import React, { useEffect } from "react";
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
TouchableOpacity,
|
||||
StyleSheet,
|
||||
SafeAreaView,
|
||||
Alert,
|
||||
Share,
|
||||
Clipboard,
|
||||
Platform,
|
||||
Linking,
|
||||
ActivityIndicator,
|
||||
} from "react-native";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import * as FileSystem from "expo-file-system";
|
||||
import * as MediaLibrary from "expo-media-library";
|
||||
import * as Sharing from "expo-sharing";
|
||||
import { Image } from "expo-image";
|
||||
import { useSelector, useDispatch } from "react-redux";
|
||||
import { RootState } from "@/store"; // Adjust path as needed
|
||||
import { updatePaymentStatus } from "@/store/paymentSlice";
|
||||
|
||||
interface UpiPaymentScreenProps {
|
||||
onBack?: () => void;
|
||||
onPaymentSuccess?: () => void;
|
||||
onPaymentFailure?: () => void;
|
||||
}
|
||||
|
||||
const UpiPaymentScreen: React.FC<UpiPaymentScreenProps> = ({
|
||||
onBack,
|
||||
onPaymentSuccess,
|
||||
onPaymentFailure,
|
||||
}) => {
|
||||
const dispatch = useDispatch();
|
||||
const paymentOrder = useSelector(
|
||||
(state: RootState) => state.payments.paymentOrder
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
// Check if payment order exists
|
||||
if (!paymentOrder) {
|
||||
Alert.alert(
|
||||
"Error",
|
||||
"No payment order found. Please create a payment order first.",
|
||||
[{ text: "OK", onPress: onBack }]
|
||||
);
|
||||
}
|
||||
}, [paymentOrder]);
|
||||
|
||||
// Show loading if no payment data
|
||||
if (!paymentOrder) {
|
||||
return (
|
||||
<SafeAreaView style={styles.container}>
|
||||
<View style={styles.loadingContainer}>
|
||||
<ActivityIndicator size="large" color="#00876F" />
|
||||
<Text style={styles.loadingText}>Loading payment details...</Text>
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
// Format amount with currency symbol
|
||||
const formatAmount = (amount: number): string => {
|
||||
return `₹${amount.toLocaleString("en-IN")}`;
|
||||
};
|
||||
|
||||
// Extract numeric amount from the amount
|
||||
const getNumericAmount = (): string => {
|
||||
return paymentOrder.amount.toString();
|
||||
};
|
||||
|
||||
// Check if payment is expired
|
||||
const isPaymentExpired = (): boolean => {
|
||||
const expiryDate = new Date(paymentOrder.expiry_date);
|
||||
const currentDate = new Date();
|
||||
return currentDate > expiryDate;
|
||||
};
|
||||
|
||||
// Get formatted expiry time
|
||||
const getExpiryTime = (): string => {
|
||||
const expiryDate = new Date(paymentOrder.expiry_date);
|
||||
return expiryDate.toLocaleString("en-IN");
|
||||
};
|
||||
|
||||
// Generate UPI payment URL from the API response
|
||||
const getUpiUrl = (): string => {
|
||||
// Use the UPI string from the API response
|
||||
const upiString = paymentOrder.ppayment_link;
|
||||
|
||||
// Extract the UPI URL from the deep link
|
||||
const upiMatch = upiString.match(/upi_string=([^&]+)/);
|
||||
if (upiMatch) {
|
||||
return decodeURIComponent(upiMatch[1]);
|
||||
}
|
||||
|
||||
// Fallback: construct UPI URL manually
|
||||
return `upi://pay?pa=${paymentOrder.upi_handle}&am=${paymentOrder.amount}&cu=INR&tr=${paymentOrder.order_id}`;
|
||||
};
|
||||
|
||||
const payUsingUpiApp = async (): Promise<void> => {
|
||||
try {
|
||||
// Check if payment is expired
|
||||
if (isPaymentExpired()) {
|
||||
Alert.alert(
|
||||
"Payment Expired",
|
||||
"This payment order has expired. Please create a new payment order.",
|
||||
[{ text: "OK", onPress: onBack }]
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if payment is already completed
|
||||
if (
|
||||
paymentOrder.status === "completed" ||
|
||||
paymentOrder.status === "success"
|
||||
) {
|
||||
Alert.alert(
|
||||
"Payment Already Completed",
|
||||
"This payment has already been completed.",
|
||||
[{ text: "OK", onPress: onPaymentSuccess }]
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const upiUrl = getUpiUrl();
|
||||
console.log("Opening UPI URL:", upiUrl);
|
||||
|
||||
// Update payment status to processing
|
||||
dispatch(updatePaymentStatus("processing"));
|
||||
|
||||
// Check if device can handle UPI URLs
|
||||
const canOpenUrl = await Linking.canOpenURL(upiUrl);
|
||||
|
||||
if (canOpenUrl) {
|
||||
await Linking.openURL(upiUrl);
|
||||
|
||||
// Show payment status dialog
|
||||
Alert.alert(
|
||||
"Payment Initiated",
|
||||
"Payment has been initiated. Please complete the payment in your UPI app.",
|
||||
[
|
||||
{
|
||||
text: "Payment Completed",
|
||||
onPress: () => {
|
||||
dispatch(updatePaymentStatus("completed"));
|
||||
onPaymentSuccess?.();
|
||||
},
|
||||
},
|
||||
{
|
||||
text: "Payment Failed",
|
||||
style: "cancel",
|
||||
onPress: () => {
|
||||
dispatch(updatePaymentStatus("failed"));
|
||||
onPaymentFailure?.();
|
||||
},
|
||||
},
|
||||
]
|
||||
);
|
||||
} else {
|
||||
// Fallback: Show available UPI apps or general share
|
||||
Alert.alert(
|
||||
"UPI App Required",
|
||||
"Please install a UPI-enabled app like PhonePe, Paytm, Google Pay, or BHIM to make payments.",
|
||||
[
|
||||
{
|
||||
text: "Share Payment Details",
|
||||
onPress: () => sharePaymentDetails(),
|
||||
},
|
||||
{ text: "Cancel", style: "cancel" },
|
||||
]
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error opening UPI app:", error);
|
||||
dispatch(updatePaymentStatus("failed"));
|
||||
Alert.alert(
|
||||
"Error",
|
||||
"Unable to open UPI app. Would you like to share payment details instead?",
|
||||
[
|
||||
{
|
||||
text: "Share Details",
|
||||
onPress: () => sharePaymentDetails(),
|
||||
},
|
||||
{ text: "Cancel", style: "cancel" },
|
||||
]
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const sharePaymentDetails = async (): Promise<void> => {
|
||||
try {
|
||||
const shareMessage =
|
||||
`💰 Payment Request\n\n` +
|
||||
`Amount: ${formatAmount(paymentOrder.amount)}\n` +
|
||||
`UPI ID: ${paymentOrder.upi_handle}\n` +
|
||||
`Order ID: ${paymentOrder.order_id}\n` +
|
||||
`Transaction ID: ${paymentOrder.transaction_id}\n` +
|
||||
`Expires: ${getExpiryTime()}\n\n` +
|
||||
`UPI Link: ${getUpiUrl()}`;
|
||||
|
||||
await Share.share({
|
||||
message: shareMessage,
|
||||
title: "UPI Payment Details",
|
||||
});
|
||||
} catch (error) {
|
||||
Alert.alert("Error", "Failed to share payment details");
|
||||
}
|
||||
};
|
||||
|
||||
const copyUpiId = async (): Promise<void> => {
|
||||
try {
|
||||
await Clipboard.setString(paymentOrder.upi_handle);
|
||||
Alert.alert("Copied!", "UPI ID copied to clipboard");
|
||||
} catch (error) {
|
||||
Alert.alert("Error", "Failed to copy UPI ID");
|
||||
}
|
||||
};
|
||||
|
||||
const shareQR = async (): Promise<void> => {
|
||||
try {
|
||||
if (await Sharing.isAvailableAsync()) {
|
||||
await Share.share({
|
||||
message: `Pay ${formatAmount(
|
||||
paymentOrder.amount
|
||||
)} using this QR code`,
|
||||
url: paymentOrder.qr_code_url,
|
||||
});
|
||||
} else {
|
||||
Alert.alert("Error", "Sharing is not available on this device");
|
||||
}
|
||||
} catch (error) {
|
||||
Alert.alert("Error", "Failed to share QR code");
|
||||
}
|
||||
};
|
||||
|
||||
const downloadQR = async (): Promise<void> => {
|
||||
try {
|
||||
const { status } = await MediaLibrary.requestPermissionsAsync();
|
||||
if (status !== "granted") {
|
||||
Alert.alert(
|
||||
"Permission Required",
|
||||
"Please grant permission to save images"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const fileUri =
|
||||
FileSystem.documentDirectory + `qr-${paymentOrder.order_id}.png`;
|
||||
const downloadResult = await FileSystem.downloadAsync(
|
||||
paymentOrder.qr_code_url,
|
||||
fileUri
|
||||
);
|
||||
|
||||
if (downloadResult.status === 200) {
|
||||
const asset = await MediaLibrary.createAssetAsync(downloadResult.uri);
|
||||
await MediaLibrary.createAlbumAsync("Payment QR Codes", asset, false);
|
||||
Alert.alert("Success", "QR code saved to gallery");
|
||||
} else {
|
||||
Alert.alert("Error", "Failed to download QR code");
|
||||
}
|
||||
} catch (error) {
|
||||
Alert.alert("Error", "Failed to download QR code");
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusColor = (status: string): string => {
|
||||
switch (status) {
|
||||
case "completed":
|
||||
case "success":
|
||||
return "#00876F";
|
||||
case "failed":
|
||||
return "#E74C3C";
|
||||
case "processing":
|
||||
return "#F39C12";
|
||||
default:
|
||||
return "#6C757D";
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<SafeAreaView style={styles.container}>
|
||||
<View style={styles.header}>
|
||||
<TouchableOpacity onPress={onBack} style={styles.backButton}>
|
||||
<Ionicons name="chevron-back" size={24} color="#253342" />
|
||||
</TouchableOpacity>
|
||||
<Text style={styles.headerTitle}>Pay EMI</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.content}>
|
||||
<View style={styles.qrFrame}>
|
||||
<View style={styles.amountSection}>
|
||||
<Text style={styles.amountLabel}>Amount to be paid</Text>
|
||||
<Text style={styles.amount}>
|
||||
{formatAmount(paymentOrder.amount)}
|
||||
</Text>
|
||||
|
||||
{/* Payment Status */}
|
||||
<View
|
||||
style={[
|
||||
styles.statusBadge,
|
||||
{ backgroundColor: getStatusColor(paymentOrder.status) },
|
||||
]}
|
||||
>
|
||||
<Text style={styles.statusText}>
|
||||
{paymentOrder.status.toUpperCase()}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.qrCodeContainer}>
|
||||
<Image
|
||||
source={{ uri: paymentOrder.qr_code_url }}
|
||||
style={styles.qrCode}
|
||||
contentFit="contain"
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* UPI ID Section */}
|
||||
<TouchableOpacity onPress={copyUpiId} style={styles.upiIdSection}>
|
||||
<Text style={styles.upiIdText}>{paymentOrder.upi_handle}</Text>
|
||||
<Ionicons name="copy-outline" size={16} color="#253342" />
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* Expiry Info */}
|
||||
<View style={styles.expirySection}>
|
||||
<Text style={styles.expiryLabel}>Expires:</Text>
|
||||
<Text style={styles.expiryTime}>{getExpiryTime()}</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.buttonsContainer}>
|
||||
<TouchableOpacity onPress={shareQR} style={styles.secondaryButton}>
|
||||
<Ionicons name="share-outline" size={20} color="#253342" />
|
||||
<Text style={styles.secondaryButtonText}>Share QR</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
onPress={downloadQR}
|
||||
style={styles.secondaryButton}
|
||||
>
|
||||
<Ionicons name="download-outline" size={20} color="#253342" />
|
||||
<Text style={styles.secondaryButtonText}>Download QR</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<TouchableOpacity
|
||||
onPress={payUsingUpiApp}
|
||||
style={[
|
||||
styles.primaryButton,
|
||||
(isPaymentExpired() || paymentOrder.status === "completed") &&
|
||||
styles.disabledButton,
|
||||
]}
|
||||
disabled={isPaymentExpired() || paymentOrder.status === "completed"}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
styles.primaryButtonText,
|
||||
(isPaymentExpired() || paymentOrder.status === "completed") &&
|
||||
styles.disabledButtonText,
|
||||
]}
|
||||
>
|
||||
{paymentOrder.status === "completed"
|
||||
? "Payment Completed"
|
||||
: isPaymentExpired()
|
||||
? "Payment Expired"
|
||||
: "Pay using UPI app"}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: "#F3F5F8",
|
||||
},
|
||||
loadingContainer: {
|
||||
flex: 1,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
},
|
||||
loadingText: {
|
||||
marginTop: 16,
|
||||
fontSize: 16,
|
||||
color: "#253342",
|
||||
},
|
||||
header: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 12,
|
||||
backgroundColor: "#FFFFFF",
|
||||
},
|
||||
backButton: {
|
||||
padding: 8,
|
||||
marginRight: 8,
|
||||
},
|
||||
headerTitle: {
|
||||
fontSize: 18,
|
||||
fontWeight: "600",
|
||||
color: "#253342",
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
justifyContent: "space-between",
|
||||
paddingHorizontal: 16,
|
||||
paddingBottom: 16,
|
||||
},
|
||||
qrFrame: {
|
||||
backgroundColor: "#FCFCFC",
|
||||
borderRadius: 8,
|
||||
padding: 16,
|
||||
alignItems: "center",
|
||||
marginTop: 16,
|
||||
},
|
||||
amountSection: {
|
||||
alignItems: "center",
|
||||
marginBottom: 16,
|
||||
},
|
||||
amountLabel: {
|
||||
fontSize: 14,
|
||||
color: "#253342",
|
||||
textAlign: "center",
|
||||
marginBottom: 4,
|
||||
},
|
||||
amount: {
|
||||
fontSize: 20,
|
||||
fontWeight: "600",
|
||||
color: "#253342",
|
||||
textAlign: "center",
|
||||
marginBottom: 8,
|
||||
},
|
||||
statusBadge: {
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 4,
|
||||
borderRadius: 12,
|
||||
},
|
||||
statusText: {
|
||||
fontSize: 12,
|
||||
fontWeight: "600",
|
||||
color: "#FFFFFF",
|
||||
},
|
||||
qrCodeContainer: {
|
||||
width: 248,
|
||||
height: 248,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
marginBottom: 16,
|
||||
},
|
||||
qrCode: {
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
},
|
||||
upiIdSection: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
marginBottom: 12,
|
||||
padding: 8,
|
||||
backgroundColor: "#F3F5F8",
|
||||
borderRadius: 4,
|
||||
},
|
||||
upiIdText: {
|
||||
fontSize: 14,
|
||||
color: "#253342",
|
||||
marginRight: 8,
|
||||
fontWeight: "500",
|
||||
},
|
||||
expirySection: {
|
||||
alignItems: "center",
|
||||
marginBottom: 16,
|
||||
},
|
||||
expiryLabel: {
|
||||
fontSize: 12,
|
||||
color: "#6C757D",
|
||||
marginBottom: 2,
|
||||
},
|
||||
expiryTime: {
|
||||
fontSize: 14,
|
||||
color: "#253342",
|
||||
fontWeight: "500",
|
||||
},
|
||||
buttonsContainer: {
|
||||
flexDirection: "row",
|
||||
gap: 8,
|
||||
width: "100%",
|
||||
},
|
||||
secondaryButton: {
|
||||
flex: 1,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
paddingVertical: 10,
|
||||
paddingHorizontal: 16,
|
||||
backgroundColor: "#F3F5F8",
|
||||
borderColor: "#D8DDE7",
|
||||
borderWidth: 1,
|
||||
borderRadius: 4,
|
||||
gap: 4,
|
||||
},
|
||||
secondaryButtonText: {
|
||||
fontSize: 14,
|
||||
fontWeight: "500",
|
||||
color: "#253342",
|
||||
},
|
||||
primaryButton: {
|
||||
backgroundColor: "#00876F",
|
||||
paddingVertical: 12,
|
||||
paddingHorizontal: 16,
|
||||
borderRadius: 4,
|
||||
alignItems: "center",
|
||||
},
|
||||
primaryButtonText: {
|
||||
fontSize: 14,
|
||||
fontWeight: "500",
|
||||
color: "#FCFCFC",
|
||||
},
|
||||
disabledButton: {
|
||||
backgroundColor: "#D8DDE7",
|
||||
},
|
||||
disabledButtonText: {
|
||||
color: "#6C757D",
|
||||
},
|
||||
});
|
||||
|
||||
export default UpiPaymentScreen;
|
||||
|
|
@ -0,0 +1,433 @@
|
|||
import React, { useState } from "react";
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
TouchableOpacity,
|
||||
TextInput,
|
||||
ScrollView,
|
||||
Platform,
|
||||
KeyboardAvoidingView,
|
||||
} from "react-native";
|
||||
import { useDispatch, useSelector } from "react-redux";
|
||||
import { Formik } from "formik";
|
||||
import * as Yup from "yup";
|
||||
import Header from "../../components/common/Header";
|
||||
import { RootState } from "@/store/rootReducer";
|
||||
import { BASE_URL, payments } from "@/constants/config";
|
||||
import api from "@/services/axiosClient";
|
||||
import { setPaymentOrder } from "@/store/paymentSlice";
|
||||
import { Overlay } from "@/components/common/Overlay";
|
||||
import { useRouter } from "expo-router";
|
||||
|
||||
// Validation schema
|
||||
const validationSchema = Yup.object().shape({
|
||||
paymentType: Yup.string().required("Please select a payment option"),
|
||||
customAmount: Yup.string().when("paymentType", {
|
||||
is: "custom",
|
||||
then: (schema) =>
|
||||
schema
|
||||
.required("Amount is required")
|
||||
.test(
|
||||
"min-amount",
|
||||
`Minimum amount is ₹${payments.MIN_AMOUNT}`,
|
||||
(value) => {
|
||||
const numValue = parseFloat(value || "0");
|
||||
return numValue >= payments.MIN_AMOUNT;
|
||||
}
|
||||
)
|
||||
.test("valid-number", "Please enter a valid amount", (value) => {
|
||||
const numValue = parseFloat(value || "0");
|
||||
return !isNaN(numValue) && numValue > 0;
|
||||
}),
|
||||
otherwise: (schema) => schema.notRequired(),
|
||||
}),
|
||||
});
|
||||
|
||||
const SelectAmountScreen = () => {
|
||||
// Fetch due amount from Redux
|
||||
const dueAmount = useSelector(
|
||||
(state: RootState) => state.payments?.due_amount || 0
|
||||
);
|
||||
|
||||
const router = useRouter();
|
||||
const [isFetching, setIsFetching] = useState<boolean>(false);
|
||||
|
||||
const quickAmounts = [50, 100, 500, 1000];
|
||||
|
||||
const initialValues = {
|
||||
paymentType: "due",
|
||||
customAmount: "",
|
||||
};
|
||||
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const handleSubmit = async (values: any) => {
|
||||
setIsFetching(true);
|
||||
const paymentAmount =
|
||||
values.paymentType === "due"
|
||||
? dueAmount
|
||||
: parseFloat(values.customAmount);
|
||||
console.log(values, "values");
|
||||
try {
|
||||
const res = await api.post(`/api/v1/create-order`, {
|
||||
amount: paymentAmount,
|
||||
});
|
||||
|
||||
console.log(res, "response");
|
||||
if (res.data && res.data.success) {
|
||||
dispatch(setPaymentOrder(res.data.data));
|
||||
router.push("/payments/payEmi");
|
||||
} else {
|
||||
throw new Error("Failed to create order");
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err, "Error in creating order.");
|
||||
} finally {
|
||||
setIsFetching(false);
|
||||
}
|
||||
|
||||
console.log("Payment Amount:", paymentAmount);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Formik
|
||||
initialValues={initialValues}
|
||||
validationSchema={validationSchema}
|
||||
onSubmit={handleSubmit}
|
||||
>
|
||||
{({
|
||||
values,
|
||||
errors,
|
||||
touched,
|
||||
handleChange,
|
||||
handleBlur,
|
||||
handleSubmit,
|
||||
setFieldValue,
|
||||
isValid,
|
||||
dirty,
|
||||
}) => {
|
||||
const handleQuickAmountPress = (amount: number) => {
|
||||
const currentAmount = values.customAmount
|
||||
? parseFloat(values.customAmount)
|
||||
: 0;
|
||||
const newAmount = currentAmount + amount;
|
||||
setFieldValue("customAmount", newAmount.toString());
|
||||
};
|
||||
|
||||
const getPaymentAmount = () => {
|
||||
if (values.paymentType === "due") {
|
||||
return dueAmount;
|
||||
}
|
||||
return values.customAmount ? parseFloat(values.customAmount) : 0;
|
||||
};
|
||||
|
||||
const isPayButtonEnabled = () => {
|
||||
if (values.paymentType === "due") return true;
|
||||
return isValid && dirty && values.customAmount;
|
||||
};
|
||||
|
||||
return (
|
||||
<KeyboardAvoidingView
|
||||
style={styles.container}
|
||||
behavior={Platform.OS === "ios" ? "padding" : "height"}
|
||||
keyboardVerticalOffset={Platform.OS === "ios" ? 0 : 20}
|
||||
>
|
||||
<Header title="Select Amount" showBackButton={true} />
|
||||
|
||||
<ScrollView
|
||||
style={styles.content}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
<View style={styles.selectAmountContainer}>
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.option,
|
||||
values.paymentType === "due" && styles.selectedOption,
|
||||
]}
|
||||
onPress={() => setFieldValue("paymentType", "due")}
|
||||
>
|
||||
<View style={styles.radioContainer}>
|
||||
<View
|
||||
style={[
|
||||
styles.radioDot,
|
||||
values.paymentType === "due" &&
|
||||
styles.selectedRadioDot,
|
||||
]}
|
||||
>
|
||||
{values.paymentType === "due" && (
|
||||
<View style={styles.radioInner} />
|
||||
)}
|
||||
</View>
|
||||
<Text style={styles.radioLabel}>Pay amount due</Text>
|
||||
</View>
|
||||
<Text style={styles.amountText}>
|
||||
₹{dueAmount?.toFixed(2)}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<View
|
||||
style={[
|
||||
styles.customOption,
|
||||
values.paymentType === "custom" && styles.selectedOption,
|
||||
touched.customAmount &&
|
||||
errors.customAmount &&
|
||||
styles.errorOption,
|
||||
]}
|
||||
>
|
||||
<TouchableOpacity
|
||||
style={styles.radioContainer}
|
||||
onPress={() => setFieldValue("paymentType", "custom")}
|
||||
>
|
||||
<View
|
||||
style={[
|
||||
styles.radioDot,
|
||||
values.paymentType === "custom" &&
|
||||
styles.selectedRadioDot,
|
||||
]}
|
||||
>
|
||||
{values.paymentType === "custom" && (
|
||||
<View style={styles.radioInner} />
|
||||
)}
|
||||
</View>
|
||||
<Text style={styles.radioLabel}>Enter custom amount</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<View style={styles.inputContainer}>
|
||||
<TextInput
|
||||
style={[
|
||||
styles.textInput,
|
||||
touched.customAmount &&
|
||||
errors.customAmount &&
|
||||
styles.errorInput,
|
||||
]}
|
||||
value={values.customAmount}
|
||||
onChangeText={(text) => {
|
||||
handleChange("customAmount")(text);
|
||||
setFieldValue("paymentType", "custom");
|
||||
}}
|
||||
onBlur={handleBlur("customAmount")}
|
||||
placeholder="₹"
|
||||
placeholderTextColor="#94A3B8"
|
||||
keyboardType="numeric"
|
||||
onFocus={() => setFieldValue("paymentType", "custom")}
|
||||
/>
|
||||
<View style={styles.helperContainer}>
|
||||
<Text
|
||||
style={[
|
||||
styles.helperText,
|
||||
touched.customAmount &&
|
||||
errors.customAmount &&
|
||||
styles.errorText,
|
||||
]}
|
||||
>
|
||||
{touched.customAmount && errors.customAmount
|
||||
? errors.customAmount
|
||||
: "Minimum: ₹200"}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.chipsContainer}>
|
||||
{quickAmounts.map((amount) => (
|
||||
<TouchableOpacity
|
||||
key={amount}
|
||||
style={styles.chip}
|
||||
onPress={() => handleQuickAmountPress(amount)}
|
||||
>
|
||||
<Text style={styles.chipText}>+₹{amount}</Text>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* General form error */}
|
||||
{touched.paymentType && errors.paymentType && (
|
||||
<Text style={styles.generalErrorText}>
|
||||
{errors.paymentType}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
</ScrollView>
|
||||
|
||||
{/* Pay Button */}
|
||||
<View style={styles.buttonContainer}>
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.payButton,
|
||||
!isPayButtonEnabled() && styles.disabledButton,
|
||||
]}
|
||||
onPress={() => handleSubmit()}
|
||||
disabled={!isPayButtonEnabled()}
|
||||
>
|
||||
{getPaymentAmount() < 200 ? (
|
||||
<Text style={styles.payButtonText}>Select Amount</Text>
|
||||
) : (
|
||||
<Text style={styles.payButtonText}>
|
||||
Pay ₹{getPaymentAmount().toFixed(2)}
|
||||
</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</KeyboardAvoidingView>
|
||||
);
|
||||
}}
|
||||
</Formik>
|
||||
<Overlay isUploading={isFetching} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: "#F3F4F6",
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
paddingHorizontal: 16,
|
||||
},
|
||||
selectAmountContainer: {
|
||||
paddingTop: 16,
|
||||
gap: 16,
|
||||
},
|
||||
option: {
|
||||
backgroundColor: "#FCFCFC",
|
||||
borderRadius: 8,
|
||||
padding: 16,
|
||||
borderWidth: 1,
|
||||
borderColor: "transparent",
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
minHeight: 56,
|
||||
},
|
||||
selectedOption: {
|
||||
borderColor: "#009E71",
|
||||
},
|
||||
errorOption: {
|
||||
borderColor: "#EF4444",
|
||||
},
|
||||
customOption: {
|
||||
backgroundColor: "#FCFCFC",
|
||||
borderRadius: 8,
|
||||
padding: 16,
|
||||
borderWidth: 1,
|
||||
borderColor: "transparent",
|
||||
minHeight: 180,
|
||||
},
|
||||
radioContainer: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
flex: 1,
|
||||
marginBottom: 16,
|
||||
},
|
||||
radioDot: {
|
||||
width: 18,
|
||||
height: 18,
|
||||
borderRadius: 9,
|
||||
borderWidth: 2,
|
||||
borderColor: "#D1D5DB",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
marginRight: 12,
|
||||
},
|
||||
selectedRadioDot: {
|
||||
borderColor: "#009E71",
|
||||
},
|
||||
radioInner: {
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: 4,
|
||||
backgroundColor: "#009E71",
|
||||
},
|
||||
radioLabel: {
|
||||
fontSize: 14,
|
||||
color: "#252936",
|
||||
fontWeight: "400",
|
||||
},
|
||||
amountText: {
|
||||
fontSize: 14,
|
||||
color: "#252936",
|
||||
fontWeight: "400",
|
||||
},
|
||||
inputContainer: {
|
||||
marginBottom: 16,
|
||||
},
|
||||
textInput: {
|
||||
backgroundColor: "#FFFFFF",
|
||||
borderWidth: 1,
|
||||
borderColor: "#D8DDE7",
|
||||
borderRadius: 4,
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 10,
|
||||
fontSize: 14,
|
||||
color: "#252936",
|
||||
height: 40,
|
||||
},
|
||||
errorInput: {
|
||||
borderColor: "#EF4444",
|
||||
},
|
||||
helperContainer: {
|
||||
marginTop: 4,
|
||||
},
|
||||
helperText: {
|
||||
fontSize: 14,
|
||||
color: "#565F70",
|
||||
},
|
||||
errorText: {
|
||||
color: "#EF4444",
|
||||
},
|
||||
generalErrorText: {
|
||||
fontSize: 14,
|
||||
color: "#EF4444",
|
||||
textAlign: "center",
|
||||
marginTop: 8,
|
||||
},
|
||||
chipsContainer: {
|
||||
flexDirection: "row",
|
||||
gap: 8,
|
||||
flexWrap: "wrap",
|
||||
},
|
||||
chip: {
|
||||
backgroundColor: "#F3F4F6",
|
||||
borderWidth: 1,
|
||||
borderColor: "#D8DDE7",
|
||||
borderRadius: 4,
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 4,
|
||||
minWidth: 68,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
height: 28,
|
||||
},
|
||||
chipText: {
|
||||
fontSize: 14,
|
||||
color: "#252936",
|
||||
fontWeight: "500",
|
||||
},
|
||||
buttonContainer: {
|
||||
padding: 16,
|
||||
backgroundColor: "#F3F4F6",
|
||||
},
|
||||
payButton: {
|
||||
backgroundColor: "#009E71",
|
||||
borderRadius: 4,
|
||||
paddingVertical: 8,
|
||||
paddingHorizontal: 16,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
height: 40,
|
||||
},
|
||||
disabledButton: {
|
||||
backgroundColor: "#94A3B8",
|
||||
},
|
||||
payButtonText: {
|
||||
fontSize: 14,
|
||||
color: "#FCFCFC",
|
||||
fontWeight: "500",
|
||||
},
|
||||
});
|
||||
|
||||
export default SelectAmountScreen;
|
||||
|
|
@ -0,0 +1,83 @@
|
|||
import React from "react";
|
||||
import { View, Text, TouchableOpacity, StyleSheet } from "react-native";
|
||||
import { useNavigation } from "@react-navigation/native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import ChevronLeft from "@/assets/icons/chevron_left.svg";
|
||||
|
||||
type HeaderProps = {
|
||||
title: string;
|
||||
showBackButton?: boolean;
|
||||
onBackPress?: () => void;
|
||||
rightComponent?: React.ReactNode;
|
||||
};
|
||||
|
||||
const Header: React.FC<HeaderProps> = ({
|
||||
title,
|
||||
showBackButton = false,
|
||||
onBackPress,
|
||||
rightComponent,
|
||||
}) => {
|
||||
const navigation = useNavigation();
|
||||
const insets = useSafeAreaInsets(); // get safe area insets
|
||||
|
||||
const handleBack = () => {
|
||||
if (onBackPress) {
|
||||
onBackPress();
|
||||
} else {
|
||||
navigation.goBack();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={[styles.container, { paddingTop: insets.top }]}>
|
||||
{/* Left side */}
|
||||
<View style={styles.leftContainer}>
|
||||
{showBackButton && (
|
||||
<TouchableOpacity style={styles.backButton} onPress={handleBack}>
|
||||
<ChevronLeft width={24} height={24} />
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
<Text style={styles.title}>{title}</Text>
|
||||
</View>
|
||||
|
||||
{/* Right side */}
|
||||
{rightComponent && (
|
||||
<View style={styles.rightContainer}>{rightComponent}</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export default Header;
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
width: "100%",
|
||||
height: 56,
|
||||
backgroundColor: "#F3F5F8",
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
paddingHorizontal: 8,
|
||||
},
|
||||
leftContainer: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
},
|
||||
backButton: {
|
||||
width: 40,
|
||||
height: 40,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
},
|
||||
title: {
|
||||
marginLeft: 8,
|
||||
fontSize: 16,
|
||||
fontWeight: "600",
|
||||
color: "#252A34",
|
||||
},
|
||||
rightContainer: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,101 @@
|
|||
import React from "react";
|
||||
import { View, Text, StyleSheet, Pressable } from "react-native";
|
||||
import ChevronRight from "../../assets/icons/chevron_rightside.svg";
|
||||
|
||||
type Props = {
|
||||
title: string;
|
||||
firstText: string;
|
||||
secondText?: string;
|
||||
percentage: number;
|
||||
onPress?: () => void;
|
||||
};
|
||||
|
||||
const ProgressCard: React.FC<Props> = ({
|
||||
title,
|
||||
firstText,
|
||||
secondText = "",
|
||||
percentage,
|
||||
onPress,
|
||||
}) => {
|
||||
return (
|
||||
<View style={styles.card}>
|
||||
<View style={styles.topRow}>
|
||||
<View style={styles.textColumn}>
|
||||
<Text style={styles.title}>{title}</Text>
|
||||
<Text style={styles.time}>
|
||||
{firstText}
|
||||
{secondText ? ` / ${secondText}` : ""}
|
||||
</Text>
|
||||
</View>
|
||||
{onPress && (
|
||||
<Pressable style={styles.iconButton} onPress={onPress}>
|
||||
<ChevronRight />
|
||||
</Pressable>
|
||||
)}
|
||||
</View>
|
||||
<View style={styles.progressContainer}>
|
||||
<View style={styles.progressBar}>
|
||||
<View style={[styles.progressFill, { width: `${percentage}%` }]} />
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
card: {
|
||||
backgroundColor: "#FCFCFC",
|
||||
borderRadius: 10,
|
||||
padding: 16,
|
||||
width: "100%",
|
||||
height: 96,
|
||||
justifyContent: "space-between",
|
||||
},
|
||||
topRow: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "flex-start",
|
||||
},
|
||||
textColumn: {
|
||||
flexDirection: "column",
|
||||
gap: 4,
|
||||
},
|
||||
title: {
|
||||
fontSize: 12,
|
||||
fontFamily: "Inter-Medium",
|
||||
color: "#565E70",
|
||||
lineHeight: 16,
|
||||
},
|
||||
time: {
|
||||
fontSize: 14,
|
||||
fontFamily: "Inter-SemiBold",
|
||||
color: "#252A34",
|
||||
lineHeight: 20,
|
||||
},
|
||||
iconButton: {
|
||||
width: 40,
|
||||
height: 40,
|
||||
padding: 10,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
},
|
||||
progressContainer: {
|
||||
marginTop: 8,
|
||||
width: "100%",
|
||||
height: 8,
|
||||
},
|
||||
progressBar: {
|
||||
width: "100%",
|
||||
height: 8,
|
||||
backgroundColor: "#E5E9F0",
|
||||
borderRadius: 10,
|
||||
overflow: "hidden",
|
||||
},
|
||||
progressFill: {
|
||||
height: "100%",
|
||||
backgroundColor: "#027A48",
|
||||
borderRadius: 10,
|
||||
},
|
||||
});
|
||||
|
||||
export default ProgressCard;
|
||||
|
|
@ -13,6 +13,7 @@ import type { BmsState } from "./types";
|
|||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export const BASE_URL = "https://dev-driver-saathi-api.vecmocon.com";
|
||||
// export const BASE_URL = "https://46fa2cacfc37.ngrok-free.app";
|
||||
|
||||
// const SERVER_URL =
|
||||
// "http://dev.vec-tr.ai:8089/?dashboardId=deviceDashboardSocket&assetId=V16000868651064644504";
|
||||
|
|
@ -176,3 +177,7 @@ export const issueConfig = [
|
|||
],
|
||||
},
|
||||
];
|
||||
|
||||
export const payments = {
|
||||
MIN_AMOUNT: 200,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -21,10 +21,13 @@
|
|||
"expo-checkbox": "^4.1.4",
|
||||
"expo-constants": "~17.1.6",
|
||||
"expo-font": "~13.3.1",
|
||||
"expo-image": "^2.4.0",
|
||||
"expo-image-picker": "~16.1.4",
|
||||
"expo-linking": "~7.1.5",
|
||||
"expo-media-library": "^17.1.7",
|
||||
"expo-router": "~5.1.1",
|
||||
"expo-secure-store": "^14.2.3",
|
||||
"expo-sharing": "^13.1.5",
|
||||
"expo-splash-screen": "~0.30.9",
|
||||
"expo-status-bar": "~2.2.3",
|
||||
"expo-system-ui": "~5.0.9",
|
||||
|
|
@ -7095,6 +7098,23 @@
|
|||
"react": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/expo-image": {
|
||||
"version": "2.4.0",
|
||||
"resolved": "https://registry.npmjs.org/expo-image/-/expo-image-2.4.0.tgz",
|
||||
"integrity": "sha512-TQ/LvrtJ9JBr+Tf198CAqflxcvdhuj7P24n0LQ1jHaWIVA7Z+zYKbYHnSMPSDMul/y0U46Z5bFLbiZiSidgcNw==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"expo": "*",
|
||||
"react": "*",
|
||||
"react-native": "*",
|
||||
"react-native-web": "*"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"react-native-web": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/expo-image-loader": {
|
||||
"version": "5.1.0",
|
||||
"resolved": "https://registry.npmjs.org/expo-image-loader/-/expo-image-loader-5.1.0.tgz",
|
||||
|
|
@ -7140,6 +7160,16 @@
|
|||
"react-native": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/expo-media-library": {
|
||||
"version": "17.1.7",
|
||||
"resolved": "https://registry.npmjs.org/expo-media-library/-/expo-media-library-17.1.7.tgz",
|
||||
"integrity": "sha512-hLCoMvlhjtt+iYxPe71P1F6t06mYGysuNOfjQzDbbf64PCkglCZJYmywPyUSV1V5Hu9DhRj//gEg+Ki+7VWXog==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"expo": "*",
|
||||
"react-native": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/expo-modules-autolinking": {
|
||||
"version": "2.1.12",
|
||||
"resolved": "https://registry.npmjs.org/expo-modules-autolinking/-/expo-modules-autolinking-2.1.12.tgz",
|
||||
|
|
@ -7230,6 +7260,15 @@
|
|||
"expo": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/expo-sharing": {
|
||||
"version": "13.1.5",
|
||||
"resolved": "https://registry.npmjs.org/expo-sharing/-/expo-sharing-13.1.5.tgz",
|
||||
"integrity": "sha512-X/5sAEiWXL2kdoGE3NO5KmbfcmaCWuWVZXHu8OQef7Yig4ZgHFkGD11HKJ5KqDrDg+SRZe4ISd6MxE7vGUgm4w==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"expo": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/expo-splash-screen": {
|
||||
"version": "0.30.9",
|
||||
"resolved": "https://registry.npmjs.org/expo-splash-screen/-/expo-splash-screen-0.30.9.tgz",
|
||||
|
|
|
|||
|
|
@ -26,10 +26,13 @@
|
|||
"expo-checkbox": "^4.1.4",
|
||||
"expo-constants": "~17.1.6",
|
||||
"expo-font": "~13.3.1",
|
||||
"expo-image": "^2.4.0",
|
||||
"expo-image-picker": "~16.1.4",
|
||||
"expo-linking": "~7.1.5",
|
||||
"expo-media-library": "^17.1.7",
|
||||
"expo-router": "~5.1.1",
|
||||
"expo-secure-store": "^14.2.3",
|
||||
"expo-sharing": "^13.1.5",
|
||||
"expo-splash-screen": "~0.30.9",
|
||||
"expo-status-bar": "~2.2.3",
|
||||
"expo-system-ui": "~5.0.9",
|
||||
|
|
|
|||
|
|
@ -8,21 +8,46 @@ const api = axios.create({
|
|||
timeout: 10000,
|
||||
});
|
||||
|
||||
//Request interceptor to add auth token
|
||||
// Request interceptor to add auth token
|
||||
api.interceptors.request.use(async (config) => {
|
||||
const token = await AsyncStorage.getItem(STORAGE_KEYS.AUTH_TOKEN);
|
||||
|
||||
// Debug log for request
|
||||
console.log("🚀 [API Request]");
|
||||
console.log("➡️ Full URL:", `${config.baseURL || ""}${config.url}`);
|
||||
console.log("➡️ Method:", config.method?.toUpperCase());
|
||||
console.log("➡️ Headers (before token):", config.headers);
|
||||
console.log("➡️ Body/Data:", config.data);
|
||||
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
console.log("✅ Auth token attached");
|
||||
} else {
|
||||
console.log("⚠️ No auth token found");
|
||||
}
|
||||
|
||||
return config;
|
||||
});
|
||||
|
||||
//Response interceptor to handle errors
|
||||
// Response interceptor to handle errors
|
||||
api.interceptors.response.use(
|
||||
(response) => response,
|
||||
(response) => {
|
||||
// Debug log for successful response
|
||||
console.log("✅ [API Response]");
|
||||
console.log("⬅️ Status:", response.status);
|
||||
console.log("⬅️ Data:", response.data);
|
||||
return response;
|
||||
},
|
||||
async (error) => {
|
||||
const status = error.response?.status;
|
||||
//if token is expired or not present, clear it from storage
|
||||
|
||||
// Debug log for error
|
||||
console.log("❌ [API Error]");
|
||||
console.log("URL:", error.config?.url);
|
||||
console.log("Status:", status);
|
||||
console.log("Response data:", error.response?.data);
|
||||
|
||||
// If token is expired or not present
|
||||
if (status === 401 || status === 403) {
|
||||
console.log("Token expired or not present");
|
||||
await AsyncStorage.removeItem(STORAGE_KEYS.AUTH_TOKEN);
|
||||
|
|
|
|||
|
|
@ -1,152 +1,61 @@
|
|||
import { BASE_URL } from "@/constants/config";
|
||||
import api from "@/services/axiosClient";
|
||||
import { toCamel } from "@/utils/Payments";
|
||||
import { createSlice, PayloadAction, createAsyncThunk } from "@reduxjs/toolkit";
|
||||
import { AxiosError } from "axios";
|
||||
// store/emiSlice.ts
|
||||
import { createSlice, PayloadAction } from "@reduxjs/toolkit";
|
||||
import { MyPlan } from "@/app/(tabs)/payments";
|
||||
|
||||
type LastEmiStatus = "pending" | "completed";
|
||||
|
||||
export interface MyPlan {
|
||||
noOfEmi: number | null;
|
||||
totalAmount: number | null;
|
||||
downPayment: number | null;
|
||||
emiAmount: number | null;
|
||||
totalEmi: number | null;
|
||||
installmentPaid: number | null;
|
||||
currentAmount: number | null;
|
||||
// Define the payment order interface based on your API response
|
||||
interface PaymentOrder {
|
||||
amount: number;
|
||||
expiry_date: string;
|
||||
order_id: string;
|
||||
ppayment_link: string;
|
||||
qr_code_url: string;
|
||||
status: string;
|
||||
transaction_id: string;
|
||||
upi_handle: string;
|
||||
}
|
||||
|
||||
export interface EmiDetails {
|
||||
dueAmount: number | null;
|
||||
totalAmountPaidInCurrentCycle: number | null;
|
||||
dueDate: string | null;
|
||||
status: LastEmiStatus | null;
|
||||
advanceBalance: number | null;
|
||||
pendingCycles: number | null;
|
||||
totalPendingInstallments: number | null;
|
||||
myPlan: MyPlan;
|
||||
interface EmiState {
|
||||
due_amount: number | null;
|
||||
myPlan: MyPlan | null;
|
||||
paymentOrder: PaymentOrder | null;
|
||||
}
|
||||
|
||||
type LoadingState = "idle" | "pending" | "succeeded" | "failed";
|
||||
|
||||
export interface EmiDetailsState {
|
||||
item?: EmiDetails;
|
||||
loading: LoadingState;
|
||||
error?: string;
|
||||
lastFetchedAt?: number;
|
||||
}
|
||||
|
||||
const initialState: EmiDetailsState = {
|
||||
item: undefined,
|
||||
loading: "idle",
|
||||
error: undefined,
|
||||
lastFetchedAt: undefined,
|
||||
const initialState: EmiState = {
|
||||
due_amount: null,
|
||||
myPlan: null,
|
||||
paymentOrder: null,
|
||||
};
|
||||
|
||||
export interface MyPlanApi {
|
||||
no_of_emi: number | null;
|
||||
total_amount: number | null;
|
||||
down_payment: number | null;
|
||||
emi_amount: number | null;
|
||||
total_emi: number | null;
|
||||
installment_paid: number | null;
|
||||
current_amount: number | null;
|
||||
}
|
||||
|
||||
export interface EmiDetailsApi {
|
||||
due_amount: number | null;
|
||||
total_amount_paid_in_current_cycle: number | null;
|
||||
due_date: string | null;
|
||||
status: LastEmiStatus | null;
|
||||
advance_balance: number | null;
|
||||
pending_cycles: number | null;
|
||||
total_pending_installments: number | null;
|
||||
myPlain: MyPlanApi;
|
||||
}
|
||||
|
||||
export interface EmiDetailsResponse {
|
||||
success: boolean;
|
||||
data: EmiDetailsApi[];
|
||||
}
|
||||
|
||||
const mapEmiDetailsApiToModel = (apiObj: EmiDetailsApi): EmiDetails => ({
|
||||
dueAmount: apiObj.due_amount,
|
||||
totalAmountPaidInCurrentCycle: apiObj.total_amount_paid_in_current_cycle,
|
||||
dueDate: apiObj.due_date,
|
||||
status: apiObj.status,
|
||||
advanceBalance: apiObj.advance_balance,
|
||||
pendingCycles: apiObj.pending_cycles,
|
||||
totalPendingInstallments: apiObj.total_pending_installments,
|
||||
myPlan: {
|
||||
noOfEmi: apiObj.myPlain.no_of_emi,
|
||||
totalAmount: apiObj.myPlain.total_amount,
|
||||
downPayment: apiObj.myPlain.down_payment,
|
||||
emiAmount: apiObj.myPlain.emi_amount,
|
||||
totalEmi: apiObj.myPlain.total_emi,
|
||||
installmentPaid: apiObj.myPlain.installment_paid,
|
||||
currentAmount: apiObj.myPlain.current_amount,
|
||||
},
|
||||
});
|
||||
|
||||
export const fetchEmiDetails = createAsyncThunk<
|
||||
EmiDetails,
|
||||
void,
|
||||
{ rejectValue: string }
|
||||
>("emiDetails/fetch", async (_: void, { rejectWithValue }) => {
|
||||
try {
|
||||
const res = await api.get(`${BASE_URL}}/api/v1/emi-details`, {
|
||||
method: "GET",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
|
||||
const json = res.data;
|
||||
|
||||
const first = json?.data?.[0];
|
||||
if (!json?.success || !first) {
|
||||
return rejectWithValue("No EMI details found");
|
||||
}
|
||||
|
||||
return mapEmiDetailsApiToModel(first);
|
||||
} catch (e: any) {
|
||||
const err = e as AxiosError<any>;
|
||||
if (err.code === "ERR_CANCELED") return rejectWithValue("Request aborted");
|
||||
const msg =
|
||||
err.response?.data?.message || err.message || "Something went wrong";
|
||||
return rejectWithValue(msg);
|
||||
}
|
||||
});
|
||||
|
||||
const emiDetailsSlice = createSlice({
|
||||
name: "emiDetails",
|
||||
const emiSlice = createSlice({
|
||||
name: "emi",
|
||||
initialState,
|
||||
reducers: {
|
||||
clearEmiDetails(state) {
|
||||
state.item = undefined;
|
||||
state.error = undefined;
|
||||
state.loading = "idle";
|
||||
state.lastFetchedAt = undefined;
|
||||
setDueAmount(state, action: PayloadAction<number | null>) {
|
||||
state.due_amount = action.payload;
|
||||
},
|
||||
setMyPlan(state, action: PayloadAction<MyPlan | null>) {
|
||||
state.myPlan = action.payload;
|
||||
},
|
||||
setPaymentOrder(state, action: PayloadAction<PaymentOrder | null>) {
|
||||
state.paymentOrder = action.payload;
|
||||
},
|
||||
clearPaymentOrder(state) {
|
||||
state.paymentOrder = null;
|
||||
},
|
||||
updatePaymentStatus(state, action: PayloadAction<string>) {
|
||||
if (state.paymentOrder) {
|
||||
state.paymentOrder.status = action.payload;
|
||||
}
|
||||
},
|
||||
},
|
||||
extraReducers: (builder) => {
|
||||
builder
|
||||
.addCase(fetchEmiDetails.pending, (state) => {
|
||||
state.loading = "pending";
|
||||
state.error = undefined;
|
||||
})
|
||||
.addCase(
|
||||
fetchEmiDetails.fulfilled,
|
||||
(state, action: PayloadAction<EmiDetails>) => {
|
||||
state.loading = "succeeded";
|
||||
state.item = action.payload;
|
||||
state.lastFetchedAt = Date.now();
|
||||
}
|
||||
)
|
||||
.addCase(fetchEmiDetails.rejected, (state, action) => {
|
||||
state.loading = "failed";
|
||||
state.error = action.payload as string | undefined;
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export const { clearEmiDetails } = emiDetailsSlice.actions;
|
||||
export default emiDetailsSlice.reducer;
|
||||
export const {
|
||||
setDueAmount,
|
||||
setMyPlan,
|
||||
setPaymentOrder,
|
||||
clearPaymentOrder,
|
||||
updatePaymentStatus,
|
||||
} = emiSlice.actions;
|
||||
|
||||
export default emiSlice.reducer;
|
||||
|
|
|
|||
|
|
@ -51,6 +51,7 @@ export const getUserDetails = createAsyncThunk<UserData>(
|
|||
async (_, { rejectWithValue }) => {
|
||||
try {
|
||||
console.log("Fetching user details from API...");
|
||||
console.log("BASE_URL", BASE_URL);
|
||||
const response = await api.get(`${BASE_URL}/api/v1/get-user-details`);
|
||||
if (response.data.success) {
|
||||
console.log("User details fetched successfully:", response.data.data);
|
||||
|
|
|
|||
Loading…
Reference in New Issue