Add Select Amount and Pay EMI screens

feature/app-setup
vinay kumar 2025-08-11 19:53:22 +05:30
parent 9d5869c3c2
commit d12efb4fd5
13 changed files with 1575 additions and 156 deletions

View File

@ -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");
}
};

View File

@ -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":

277
app/payments/myPlan.tsx Normal file
View File

@ -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;

529
app/payments/payEmi.tsx Normal file
View File

@ -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;

View File

@ -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;

View File

@ -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",
},
});

View File

@ -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;

View File

@ -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,
};

39
package-lock.json generated
View File

@ -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",

View File

@ -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",

View File

@ -11,18 +11,43 @@ const api = axios.create({
// 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
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);

View File

@ -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;
},
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();
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;
}
)
.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;

View File

@ -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);