diff --git a/app/(tabs)/_layout.tsx b/app/(tabs)/_layout.tsx index b01043c..0d4a668 100644 --- a/app/(tabs)/_layout.tsx +++ b/app/(tabs)/_layout.tsx @@ -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"); } }; diff --git a/app/(tabs)/payments.tsx b/app/(tabs)/payments.tsx index 6e709ee..7737e47 100644 --- a/app/(tabs)/payments.tsx +++ b/app/(tabs)/payments.tsx @@ -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( [] @@ -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() { )} - {/* Divider */} - {/* Action Buttons */} - + router.push("/payments/selectAmount")} + > Pay EMI - + router.push("/payments/myPlan")} + > View Plan @@ -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": diff --git a/app/payments/myPlan.tsx b/app/payments/myPlan.tsx new file mode 100644 index 0000000..6bd3313 --- /dev/null +++ b/app/payments/myPlan.tsx @@ -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 ( + +
+ + + {/* Plan Details Card */} + + Plan Details + + + + {/* Plan Type Row */} + + Plan Type + + {displayValue(myPlan?.no_of_emi)} + + + + {/* Total Cost Row */} + + Total Cost + + {formatCurrency(myPlan?.total_amount)} + + + + {/* Down Payment Row */} + + Down Payment + + {formatCurrency(myPlan?.down_payment)} + + + + {/* Total EMI Row */} + + Total EMI + + {formatCurrency(myPlan?.total_emi)} + + + + + + {/* Total Amount Due Card */} + + Total Amount Due + + {formatCurrency(dueAmount)} + + + + + {/* Monthly EMI Card */} + + Monthly EMI + + {formatCurrency(myPlan?.total_emi)} + + + + + Installments Paid + + + {displayValue(myPlan?.installment_paid)} + + + / {displayValue(myPlan?.no_of_emi)} + + + + + + + + + Pay EMI + + + + ); +}; + +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; diff --git a/app/payments/payEmi.tsx b/app/payments/payEmi.tsx new file mode 100644 index 0000000..146e406 --- /dev/null +++ b/app/payments/payEmi.tsx @@ -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 = ({ + 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 ( + + + + Loading payment details... + + + ); + } + + // 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 => { + 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 => { + 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 => { + 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 => { + 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 => { + 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 ( + + + + + + Pay EMI + + + + + + Amount to be paid + + {formatAmount(paymentOrder.amount)} + + + {/* Payment Status */} + + + {paymentOrder.status.toUpperCase()} + + + + + + + + + {/* UPI ID Section */} + + {paymentOrder.upi_handle} + + + + {/* Expiry Info */} + + Expires: + {getExpiryTime()} + + + + + + Share QR + + + + + Download QR + + + + + + + {paymentOrder.status === "completed" + ? "Payment Completed" + : isPaymentExpired() + ? "Payment Expired" + : "Pay using UPI app"} + + + + + ); +}; + +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; diff --git a/app/payments/selectAmount.tsx b/app/payments/selectAmount.tsx new file mode 100644 index 0000000..cfc6201 --- /dev/null +++ b/app/payments/selectAmount.tsx @@ -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(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 ( + <> + + {({ + 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 ( + +
+ + + + setFieldValue("paymentType", "due")} + > + + + {values.paymentType === "due" && ( + + )} + + Pay amount due + + + ₹{dueAmount?.toFixed(2)} + + + + + setFieldValue("paymentType", "custom")} + > + + {values.paymentType === "custom" && ( + + )} + + Enter custom amount + + + + { + handleChange("customAmount")(text); + setFieldValue("paymentType", "custom"); + }} + onBlur={handleBlur("customAmount")} + placeholder="₹" + placeholderTextColor="#94A3B8" + keyboardType="numeric" + onFocus={() => setFieldValue("paymentType", "custom")} + /> + + + {touched.customAmount && errors.customAmount + ? errors.customAmount + : "Minimum: ₹200"} + + + + + + {quickAmounts.map((amount) => ( + handleQuickAmountPress(amount)} + > + +₹{amount} + + ))} + + + + {/* General form error */} + {touched.paymentType && errors.paymentType && ( + + {errors.paymentType} + + )} + + + + {/* Pay Button */} + + handleSubmit()} + disabled={!isPayButtonEnabled()} + > + {getPaymentAmount() < 200 ? ( + Select Amount + ) : ( + + Pay ₹{getPaymentAmount().toFixed(2)} + + )} + + + + ); + }} + + + + ); +}; + +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; diff --git a/components/common/Header.tsx b/components/common/Header.tsx new file mode 100644 index 0000000..0637b9f --- /dev/null +++ b/components/common/Header.tsx @@ -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 = ({ + title, + showBackButton = false, + onBackPress, + rightComponent, +}) => { + const navigation = useNavigation(); + const insets = useSafeAreaInsets(); // get safe area insets + + const handleBack = () => { + if (onBackPress) { + onBackPress(); + } else { + navigation.goBack(); + } + }; + + return ( + + {/* Left side */} + + {showBackButton && ( + + + + )} + {title} + + + {/* Right side */} + {rightComponent && ( + {rightComponent} + )} + + ); +}; + +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", + }, +}); diff --git a/components/common/ProgressCard.tsx b/components/common/ProgressCard.tsx new file mode 100644 index 0000000..3ef21b5 --- /dev/null +++ b/components/common/ProgressCard.tsx @@ -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 = ({ + title, + firstText, + secondText = "", + percentage, + onPress, +}) => { + return ( + + + + {title} + + {firstText} + {secondText ? ` / ${secondText}` : ""} + + + {onPress && ( + + + + )} + + + + + + + + ); +}; + +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; diff --git a/constants/config.ts b/constants/config.ts index b584ed4..0ca52e3 100644 --- a/constants/config.ts +++ b/constants/config.ts @@ -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, +}; diff --git a/package-lock.json b/package-lock.json index cc64c0b..6cb7ae4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 8dafda8..bd34f5d 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/services/axiosClient.ts b/services/axiosClient.ts index e962d6a..1172aa3 100644 --- a/services/axiosClient.ts +++ b/services/axiosClient.ts @@ -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); diff --git a/store/paymentSlice.ts b/store/paymentSlice.ts index e7ebf71..f6b334b 100644 --- a/store/paymentSlice.ts +++ b/store/paymentSlice.ts @@ -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; - 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) { + state.due_amount = action.payload; + }, + setMyPlan(state, action: PayloadAction) { + state.myPlan = action.payload; + }, + setPaymentOrder(state, action: PayloadAction) { + state.paymentOrder = action.payload; + }, + clearPaymentOrder(state) { + state.paymentOrder = null; + }, + updatePaymentStatus(state, action: PayloadAction) { + 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) => { - 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; diff --git a/store/userSlice.ts b/store/userSlice.ts index f79ce7b..187fcb1 100644 --- a/store/userSlice.ts +++ b/store/userSlice.ts @@ -51,6 +51,7 @@ export const getUserDetails = createAsyncThunk( 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);