fix UI issues and implement Transaction Detail Screen

feature/app-setup
vinay kumar 2025-08-14 22:55:43 +05:30
parent b648a485be
commit 75a3eb87e1
18 changed files with 849 additions and 250 deletions

View File

@ -6,7 +6,7 @@ import { AppDispatch, RootState } from "@/store";
import { initSocket } from "@/services/socket";
import { useSnackbar } from "@/contexts/Snackbar";
import NetInfo from "@react-native-community/netinfo";
import { getUserDetails } from "@/store/userSlice";
import { getPaymentSummary, getUserDetails } from "@/store/userSlice";
import { useDispatch } from "react-redux";
import { logout } from "@/store/authSlice";
@ -35,6 +35,7 @@ export default function TabLayout() {
const fetchAndInit = async () => {
try {
const result = await dispatch(getUserDetails()).unwrap();
await dispatch(getPaymentSummary());
console.log("User details fetched", result);
initSocket();
} catch (error) {

View File

@ -1,4 +1,6 @@
import {
Animated,
Easing,
Pressable,
ScrollView,
StyleSheet,
@ -26,8 +28,16 @@ import ProfileImage from "@/components/home/Profile";
import { calculateBearing, calculateDistance } from "@/utils/Map";
import { useDispatch } from "react-redux";
import RefreshIcon from "@/assets/icons/refresh.svg";
import { disconnectSocket, initSocket } from "@/services/socket";
import { setTelemetryError, setTelemetryLoading } from "@/store/telemetrySlice";
import { payments } from "@/constants/config";
import {
clearUser,
getPaymentSummary,
getUserDetails,
} from "@/store/userSlice";
import api from "@/services/axiosClient";
import { setDueAmount } from "@/store/paymentSlice";
import { EmiResponse } from "./payments";
export default function HomeScreen() {
const { t } = useTranslation();
@ -35,7 +45,13 @@ export default function HomeScreen() {
const [isSupportModalVisible, setIsSupportModalVisible] = useState(false);
const { SoC, SoH, chargingState, lat, lon, loading, error, totalDistance } =
useSelector((state: RootState) => state.telemetry);
const { data } = useSelector((state: RootState) => state.user);
const [refreshing, setRefreshing] = useState(false);
const spinValue = useState(new Animated.Value(0))[0];
const { data, paymentSummary } = useSelector(
(state: RootState) => state.user
);
const [prevPosition, setPrevPosition] = useState<{
lat: number;
lon: number;
@ -110,34 +126,36 @@ export default function HomeScreen() {
),
headerRight: () => (
<View style={styles.rightContainer}>
<Pressable onPress={() => handleManualRefresh()}>
<RefreshIcon width={50} height={50} />
<Pressable onPress={handleManualRefresh} disabled={refreshing}>
<Animated.View style={{ transform: [{ rotate: spin }] }}>
<RefreshIcon
width={50}
height={50}
opacity={refreshing ? 0.5 : 1}
/>
</Animated.View>
</Pressable>
<Pressable
style={styles.supportButton}
onPress={() => {
console.log("Support Pressed");
setIsSupportModalVisible(true);
}}
onPress={() => setIsSupportModalVisible(true)}
>
<CustomerCareIcon width={50} height={50} />
</Pressable>
<Pressable
<TouchableOpacity
onPress={() => {
router.push("/user/profile");
}}
>
<ProfileImage
username={data?.name || "Vec"}
onClick={() => router.push("/user/profile")}
textSize={20}
boxSize={40}
/>
</Pressable>
</TouchableOpacity>
</View>
),
});
}, [navigation, data, model, chasisNumber]);
}, [navigation, data, model, chasisNumber, refreshing]);
const openInGoogleMaps = () => {
const url = `https://www.google.com/maps/search/?api=1&query=${lat},${lon}`;
@ -146,27 +164,85 @@ export default function HomeScreen() {
);
};
const handleManualRefresh = async () => {
dispatch(setTelemetryLoading());
const startSpin = () => {
spinValue.setValue(0);
Animated.loop(
Animated.timing(spinValue, {
toValue: 1,
duration: 1000,
easing: Easing.linear,
useNativeDriver: true,
})
).start();
};
const stopSpin = () => {
spinValue.stopAnimation();
spinValue.setValue(0);
};
const spin = spinValue.interpolate({
inputRange: [0, 1],
outputRange: ["0deg", "360deg"],
});
const handleManualRefresh = async () => {
try {
disconnectSocket();
await initSocket();
} catch (err) {
console.error("Refresh failed", err);
dispatch(setTelemetryError("Refresh failed"));
setRefreshing(true);
startSpin();
await dispatch(clearUser());
await dispatch(getUserDetails()).unwrap();
await dispatch(getPaymentSummary()).unwrap();
console.log("Manual refresh complete");
} catch (error) {
console.error("Manual refresh failed", error);
} finally {
stopSpin();
setRefreshing(false);
}
};
const {
daysLeftToPayEmi,
daysLeftToPayEmiText,
regularServiceDueInDaysText,
due_amount,
} = paymentSummary || {};
useEffect(() => {
if (paymentSummary?.due_amount != null) {
dispatch(setDueAmount(paymentSummary.due_amount));
}
}, [paymentSummary, dispatch]);
return (
<View style={{ flex: 1, backgroundColor: "#F3F5F8" }}>
<StatusBar style="dark" />
<ScrollView contentContainerStyle={styles.container} style={{ flex: 1 }}>
{daysLeftToPayEmi && daysLeftToPayEmiText && daysLeftToPayEmi > 0 && (
<ServiceReminderCard
type={
daysLeftToPayEmi >= payments.EMI_WARNING_DAYS_THRESHOLD
? "warning"
: "danger"
}
message={daysLeftToPayEmiText}
subMessage="Pay now"
redirectPath="/(tabs)/payments"
/>
)}
{regularServiceDueInDaysText && (
<ServiceReminderCard
type="info"
message="Service will be due in 3 days"
message={regularServiceDueInDaysText}
subMessage="Service Reminder"
redirectPath="/(tabs)/service"
/>
)}
<View style={styles.iconContainer}>
<SemiCircleProgress progress={SoC} status={chargingState} />
</View>
@ -178,13 +254,15 @@ export default function HomeScreen() {
unit="km"
/>
</View>
{due_amount && (
<PaymentDueCard
label="Payment Due"
amount="$2,000"
amount={due_amount}
onPress={() => {
alert("le is number pe bhej de 8685846459");
router.push("/payments/selectAmount");
}}
/>
)}
<View style={styles.map}>
{loading ? (
<View style={styles.errorContainer}>

View File

@ -1,6 +1,14 @@
import { useLayoutEffect, useMemo, useState, useEffect } from "react";
import {
useLayoutEffect,
useMemo,
useState,
useEffect,
useCallback,
} from "react";
import { useNavigation, useRouter } from "expo-router";
import {
Animated,
Easing,
NativeScrollEvent,
NativeSyntheticEvent,
Pressable,
@ -21,6 +29,10 @@ import { BASE_URL } from "@/constants/config";
import { useDispatch } from "react-redux";
import { setDueAmount, setMyPlan } from "@/store/paymentSlice";
import { ActivityIndicator } from "react-native-paper";
import { useFocusEffect } from "@react-navigation/native";
import { displayValue } from "@/utils/Common";
import RefreshIcon from "@/assets/icons/refresh.svg";
import CustomerSupport from "@/components/home/CustomerSupportModal";
export interface MyPlan {
no_of_emi: number;
@ -43,7 +55,7 @@ interface EmiDetails {
myPlain: MyPlan;
}
interface EmiResponse {
export interface EmiResponse {
success: boolean;
data: EmiDetails[];
}
@ -71,34 +83,29 @@ interface PaymentHistoryResponse {
};
}
const formatPaymentDate = (dateString: string) => {
try {
return dateString.split(",")[0];
} catch {
return dateString;
}
function formatPaymentDate(input: string): string {
const date = new Date(input);
const options: Intl.DateTimeFormatOptions = {
day: "2-digit",
month: "short",
year: "numeric",
};
return date.toLocaleDateString("en-GB", options);
}
// Format time for payment history
const formatPaymentTime = (dateString: string) => {
try {
// Expected: "12 Aug 2025, 12:38:23 pm"
const [datePart, timePart] = dateString.split(",");
// Ensure JS can parse (convert "12 Aug 2025 12:38:23 pm" to valid format)
const parsedDate = new Date(`${datePart.trim()} ${timePart.trim()}`);
if (isNaN(parsedDate.getTime())) {
return dateString;
function formatPaymentTime(input: string): string {
if (!input) return "--";
const date = new Date(input);
const timeStr = date.toLocaleTimeString("en-US", {
hour: "numeric",
minute: "2-digit",
hour12: true,
});
const dayStr = date.toLocaleDateString("en-US", { weekday: "long" });
return `${timeStr} | ${dayStr}`;
}
const dayName = parsedDate.toLocaleDateString("en-US", { weekday: "long" });
return `${timePart.trim()} | ${dayName}`;
} catch (err) {
return dateString;
}
};
export default function PaymentsTabScreen() {
const navigation = useNavigation();
const { data } = useSelector((state: RootState) => state.user);
@ -114,6 +121,8 @@ export default function PaymentsTabScreen() {
const [paymentHistory, setPaymentHistory] = useState<PaymentHistoryItem[]>(
[]
);
const [refreshing, setRefreshing] = useState(false);
const spinValue = useState(new Animated.Value(0))[0];
const [isHistoryLoading, setIsHistoryLoading] = useState(false);
const [showFullHistory, setShowFullHistory] = useState(false);
const [currentPage, setCurrentPage] = useState(1);
@ -132,11 +141,10 @@ export default function PaymentsTabScreen() {
const model = vehicle?.model ?? "---";
const chasisNumber = vehicle?.chasis_number ?? "---";
// Fetch EMI details
useEffect(() => {
const fetchEmiDetails = async () => {
try {
setIsLoading(true);
setEmiDetails(null);
const response = await api.get(`/api/v1/emi-details`);
const result: EmiResponse = response.data;
@ -161,8 +169,51 @@ export default function PaymentsTabScreen() {
}
};
const handleRefresh = async () => {
try {
setShowFullHistory(false);
setRefreshing(true);
startSpin();
await Promise.all([fetchEmiDetails(), fetchPaymentHistory(1, false)]);
console.log("Manual refresh complete");
} catch (error) {
console.error("Manual refresh failed", error);
} finally {
stopSpin();
setRefreshing(false);
}
};
const startSpin = () => {
spinValue.setValue(0);
Animated.loop(
Animated.timing(spinValue, {
toValue: 1,
duration: 1000,
easing: Easing.linear,
useNativeDriver: true,
})
).start();
};
const stopSpin = () => {
spinValue.stopAnimation();
spinValue.setValue(0);
};
const spin = spinValue.interpolate({
inputRange: [0, 1],
outputRange: ["0deg", "360deg"],
});
useFocusEffect(
useCallback(() => {
setShowFullHistory(false);
fetchEmiDetails();
}, []);
fetchPaymentHistory(1, false);
}, [])
);
useLayoutEffect(() => {
navigation.setOptions({
@ -177,6 +228,15 @@ export default function PaymentsTabScreen() {
),
headerRight: () => (
<View style={styles.rightContainer}>
<Pressable onPress={() => handleRefresh()} disabled={refreshing}>
<Animated.View style={{ transform: [{ rotate: spin }] }}>
<RefreshIcon
height={50}
width={50}
style={{ opacity: refreshing ? 0.5 : 1 }}
/>
</Animated.View>
</Pressable>
<Pressable
style={styles.supportButton}
onPress={() => {
@ -184,7 +244,7 @@ export default function PaymentsTabScreen() {
setIsSupportModalVisible(true);
}}
>
<CustomerCareIcon />
<CustomerCareIcon height={50} width={50} />
</Pressable>
<Pressable
onPress={() => {
@ -201,7 +261,7 @@ export default function PaymentsTabScreen() {
</View>
),
});
}, [navigation, data, model, chasisNumber]);
}, [navigation, data, model, chasisNumber, refreshing]);
// Format currency
const formatCurrency = (amount: number) => {
@ -307,11 +367,8 @@ export default function PaymentsTabScreen() {
setIsEndReached(isAtBottom);
};
useEffect(() => {
fetchPaymentHistory(1, false);
}, []);
return (
<>
<ScrollView
contentContainerStyle={{ paddingHorizontal: 16, paddingBottom: 125 }}
showsVerticalScrollIndicator={false}
@ -330,41 +387,43 @@ export default function PaymentsTabScreen() {
<View style={styles.divider} />
{/* EMI Details Content */}
{true && (
<View style={styles.cardContent}>
<View style={styles.detailRow}>
<Text style={styles.detailLabel}>Amount Due</Text>
<Text style={styles.detailValue}>
{emiDetails?.due_amount
? formatCurrency(emiDetails.due_amount)
: "---"}
{displayValue(emiDetails?.due_amount, formatCurrency)}
</Text>
</View>
<View style={styles.detailRow}>
<Text style={styles.detailLabel}>Amount Paid</Text>
<Text style={styles.detailValue}>
{emiDetails?.total_amount_paid_in_current_cycle &&
formatCurrency(emiDetails.total_amount_paid_in_current_cycle)}
{displayValue(
emiDetails?.total_amount_paid_in_current_cycle,
formatCurrency
)}
</Text>
</View>
<View style={styles.detailRow}>
<Text style={styles.detailLabel}>Due Date</Text>
<Text style={styles.detailValue}>
{emiDetails?.due_date && emiDetails.due_date}
{displayValue(emiDetails?.due_date)}
</Text>
</View>
<View style={styles.detailRow}>
<Text style={styles.detailLabel}>Payment Status</Text>
{emiDetails?.status ? (
<StatusBadge
label={emiDetails?.status && emiDetails.status}
type={emiDetails?.status && emiDetails.status.toLowerCase()}
label={emiDetails.status}
type={emiDetails.status.toLowerCase()}
/>
</View>
</View>
) : (
<Text style={styles.detailValue}>--</Text>
)}
</View>
</View>
<View style={styles.divider} />
@ -451,6 +510,11 @@ export default function PaymentsTabScreen() {
</View>
</View>
</ScrollView>
<CustomerSupport
visible={isSupportModalVisible}
onClose={() => setIsSupportModalVisible(false)}
/>
</>
);
}
@ -469,13 +533,7 @@ const StatusBadge = ({
backgroundColor: "#FFF0E3",
color: "#8E4400",
};
case "failed":
return {
backgroundColor: "#FDE8E7",
color: "#D51C10",
};
case "completed":
case "paid":
return {
backgroundColor: "#E8F5E8",
color: "#2D7D32",

View File

@ -138,6 +138,7 @@ const styles = StyleSheet.create({
paddingHorizontal: 16,
paddingBottom: 16,
justifyContent: "space-between",
marginTop: 8,
},
qrFrame: {
backgroundColor: "#fcfcfc",

View File

@ -0,0 +1,372 @@
import React, { useState, useEffect, useLayoutEffect } from "react";
import {
View,
Text,
StyleSheet,
TouchableOpacity,
ActivityIndicator,
} from "react-native";
import { useNavigation, useRoute } from "@react-navigation/native";
import { useRouter } from "expo-router";
import api from "@/services/axiosClient";
import { BASE_URL } from "@/constants/config";
import { useSnackbar } from "@/contexts/Snackbar";
import { SafeAreaView } from "react-native-safe-area-context";
// Import your success/failure icons
import SuccessIcon from "@/assets/icons/check_circle.svg";
import FailureIcon from "@/assets/icons/cancel.svg";
import PendingIcon from "@/assets/icons/pending.svg";
interface TransactionDetailData {
id: number;
amount: string;
status: string;
transaction_date: string | null;
upi_handle: string;
payment_mode: string[];
paid_by_upi_handle: string | null;
order_id: string;
payment_reference_id: string | null;
transaction_order_id: string;
}
interface TransactionResponse {
success: boolean;
data: {
payments: TransactionDetailData[];
pagination: {
total_records: number;
page_number: number;
page_size: number;
};
};
}
export default function TransactionDetailScreen() {
const navigation = useNavigation();
const route = useRoute();
const router = useRouter();
const { showSnackbar } = useSnackbar();
// Get payment ID from route params
const { paymentId } = route.params as { paymentId: number };
const [transactionData, setTransactionData] =
useState<TransactionDetailData | null>(null);
const [isLoading, setIsLoading] = useState(true);
useLayoutEffect(() => {
navigation.setOptions({
headerStyle: {
backgroundColor: "#F3F5F8",
},
headerTitle: "Transaction Detail",
headerTitleStyle: {
fontSize: 18,
fontWeight: "600",
color: "#111827",
},
headerLeft: () => (
<TouchableOpacity
onPress={() => router.back()}
style={styles.backButton}
>
<Text style={styles.backText}></Text>
</TouchableOpacity>
),
});
}, [navigation, router]);
useEffect(() => {
fetchTransactionDetail();
}, [paymentId]);
const fetchTransactionDetail = async () => {
try {
setIsLoading(true);
const response = await api.get(
`${BASE_URL}/api/v1/payment-history?id=${paymentId}`
);
const result: TransactionResponse = response.data;
if (result.success && result.data.payments.length > 0) {
setTransactionData(result.data.payments[0]);
} else {
showSnackbar("Transaction details not found", "error");
router.back();
}
} catch (err) {
console.error("Error fetching transaction details:", err);
const errorMessage =
err instanceof Error ? err.message : "Something went wrong";
showSnackbar(errorMessage, "error");
router.back();
} finally {
setIsLoading(false);
}
};
const formatCurrency = (amount: string) => {
return `${parseFloat(amount).toLocaleString()}`;
};
const formatDateTime = (dateString: string | null) => {
if (!dateString) return "--";
const date = new Date(dateString);
const dateStr = date.toLocaleDateString("en-IN", {
day: "2-digit",
month: "long",
year: "numeric",
});
const timeStr = date.toLocaleTimeString("en-US", {
hour: "numeric",
minute: "2-digit",
hour12: true,
});
const dayStr = date.toLocaleDateString("en-US", { weekday: "long" });
return `${dateStr}, ${timeStr}, ${dayStr}`;
};
const getStatusIcon = (status: string) => {
if (status.toLowerCase() === "confirmed") {
return <SuccessIcon width={80} height={80} />;
} else if (status.toLowerCase() === "pending") {
return <PendingIcon width={80} height={80} />;
} else {
return <FailureIcon width={80} height={80} />;
}
};
const getStatusText = (status: string) => {
switch (status.toLowerCase()) {
case "success":
case "completed":
case "confirmed":
return "Payment successful";
case "failure":
case "failed":
return "Payment failed";
case "pending":
return "Payment pending";
default:
return `Payment ${status}`;
}
};
if (isLoading) {
return (
<SafeAreaView style={styles.container}>
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color="#00BE88" />
</View>
</SafeAreaView>
);
}
if (!transactionData) {
return (
<SafeAreaView style={styles.container}>
<View style={styles.errorContainer}>
<Text style={styles.errorText}>Transaction not found</Text>
</View>
</SafeAreaView>
);
}
return (
<SafeAreaView style={styles.container}>
<View style={styles.content}>
{/* Status Icon */}
<View style={styles.iconContainer}>
{getStatusIcon(transactionData.status)}
</View>
{/* Amount */}
<Text style={styles.amount}>
{formatCurrency(transactionData.amount)}
</Text>
{/* Status Text */}
<Text style={styles.statusText}>
{getStatusText(transactionData.status)}
</Text>
{/* Date Time */}
<Text style={styles.dateTime}>
{formatDateTime(transactionData.transaction_date)}
</Text>
{/* Transaction Details Card */}
<View style={styles.detailsCard}>
<Text style={styles.detailsTitle}>Transaction Details</Text>
<DetailRow
label="Payment mode"
value={transactionData.payment_mode.join(", ") || "--"}
/>
<DetailRow
label="Paid to"
value={transactionData.upi_handle || "--"}
/>
<DetailRow
label="Paid by"
value={transactionData.paid_by_upi_handle || "--"}
/>
<DetailRow
label="Order ID"
value={transactionData.order_id || "--"}
/>
<DetailRow
label="Transaction ID"
value={transactionData.transaction_order_id || "--"}
/>
<DetailRow
label="RRN"
value={transactionData.payment_reference_id || "--"}
isLast={true}
/>
</View>
</View>
</SafeAreaView>
);
}
const DetailRow = ({
label,
value,
isLast = false,
}: {
label: string;
value: string;
isLast?: boolean;
}) => (
<View style={[styles.detailRow, isLast && styles.detailRowLast]}>
<Text style={styles.detailLabel}>{label}</Text>
<Text style={styles.detailValue}>{value}</Text>
</View>
);
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: "#F3F5F8",
},
loadingContainer: {
flex: 1,
justifyContent: "center",
alignItems: "center",
},
errorContainer: {
flex: 1,
justifyContent: "center",
alignItems: "center",
},
errorText: {
fontSize: 16,
color: "#6B7280",
},
content: {
flex: 1,
padding: 16,
alignItems: "center",
},
backButton: {
paddingLeft: 16,
paddingRight: 8,
paddingVertical: 8,
},
backText: {
fontSize: 24,
color: "#111827",
fontWeight: "300",
},
iconContainer: {
marginTop: 32,
marginBottom: 16,
},
successIcon: {
width: 60,
height: 60,
borderRadius: 30,
backgroundColor: "#00BE88",
justifyContent: "center",
alignItems: "center",
},
failureIcon: {
width: 60,
height: 60,
borderRadius: 30,
backgroundColor: "#EF4444",
justifyContent: "center",
alignItems: "center",
},
checkmark: {
color: "white",
fontSize: 24,
fontWeight: "bold",
},
xmark: {
color: "white",
fontSize: 20,
fontWeight: "bold",
},
amount: {
fontSize: 20,
fontWeight: "600",
color: "#252A34",
marginBottom: 16,
},
statusText: {
fontSize: 14,
fontWeight: "400",
color: "#252A34",
marginBottom: 4,
},
dateTime: {
fontSize: 14,
color: "#252A34",
textAlign: "center",
marginBottom: 24,
},
detailsCard: {
backgroundColor: "#FCFCFC",
borderRadius: 8,
padding: 16,
width: "100%",
},
detailsTitle: {
fontSize: 14,
fontWeight: "600",
color: "#252A34",
marginBottom: 8,
},
detailRow: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "flex-start",
paddingVertical: 8,
borderBottomWidth: 1,
borderBottomColor: "#E5E9F0",
},
detailRowLast: {
borderBottomWidth: 0,
},
detailLabel: {
fontSize: 14,
color: "#252A34",
flex: 1,
},
detailValue: {
fontSize: 14,
fontWeight: "600",
color: "#252A34",
flex: 1,
textAlign: "right",
},
});

View File

@ -1,6 +1,7 @@
import Header from "@/components/common/Header";
import ProgressCard from "@/components/common/ProgressCard";
import { RootState } from "@/store/rootReducer";
import { useRouter } from "expo-router";
import React from "react";
import { View, Text, StyleSheet, TouchableOpacity } from "react-native";
import { useSelector } from "react-redux";
@ -23,6 +24,8 @@ const MyPlanScreen: React.FC = () => {
(state: RootState) => state.payments.due_amount
);
const router = useRouter();
// Helper function to format currency
const formatCurrency = (amount?: number | null): string => {
if (amount == null) return "---";
@ -102,7 +105,7 @@ const MyPlanScreen: React.FC = () => {
<View style={styles.halfCard}>
<Text style={styles.label}>Monthly EMI</Text>
<Text style={styles.value}>
{formatCurrency(myPlan?.total_emi)}
{formatCurrency(myPlan?.emi_amount)}
</Text>
</View>
@ -129,7 +132,10 @@ const MyPlanScreen: React.FC = () => {
)}
/>
<TouchableOpacity style={styles.payButton}>
<TouchableOpacity
style={styles.payButton}
onPress={() => router.push("/payments/selectAmount")}
>
<Text style={styles.payButtonText}>Pay EMI</Text>
</TouchableOpacity>
</View>
@ -141,12 +147,6 @@ const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: "#F3F5F8",
paddingTop: 16,
},
header: {
paddingHorizontal: 16,
paddingVertical: 16,
backgroundColor: "#F3F5F8",
},
headerTitle: {
fontSize: 18,
@ -261,7 +261,7 @@ const styles = StyleSheet.create({
borderRadius: 10,
},
payButton: {
backgroundColor: "#4CAF50",
backgroundColor: "#008761",
borderRadius: 8,
paddingVertical: 16,
alignItems: "center",

View File

@ -108,7 +108,7 @@ const UpiPaymentScreen = () => {
};
const getUpiUrl = (): string => {
const upiString = paymentOrder.ppayment_link;
const upiString = paymentOrder.payment_link;
const upiMatch = upiString.match(/upi_string=([^&]+)/);
if (upiMatch) {
@ -134,32 +134,12 @@ const UpiPaymentScreen = () => {
if (canOpenUrl) {
await Linking.openURL(upiUrl);
} else {
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" },
]
);
showSnackbar("UPI App Required.", "error");
}
} 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" },
]
);
showSnackbar("Unable to open UPI App", "error");
}
};
@ -179,7 +159,7 @@ const UpiPaymentScreen = () => {
title: "UPI Payment Details",
});
} catch (error) {
Alert.alert("Error", "Failed to share payment details");
showSnackbar("Failed to share payment details", "error");
}
};
@ -204,10 +184,7 @@ const UpiPaymentScreen = () => {
try {
const { status } = await MediaLibrary.requestPermissionsAsync();
if (status !== "granted") {
Alert.alert(
"Permission Required",
"Please grant permission to save images"
);
showSnackbar("Please grant permission to save images", "error");
return;
}
@ -221,12 +198,12 @@ const UpiPaymentScreen = () => {
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");
showSnackbar("QR code saved to gallery", "success");
} else {
Alert.alert("Error", "Failed to download QR code");
showSnackbar("Failed to Download QR code", "error");
}
} catch (error) {
Alert.alert("Error", "Failed to download QR code");
showSnackbar("Failed to Download QR code", "error");
}
};

View File

@ -140,6 +140,7 @@ const SelectAmountScreen = () => {
initialValues={initialValues}
validationSchema={validationSchema}
onSubmit={handleSubmit}
// Add validateOnChange to make validation more responsive
validateOnChange={true}
validateOnBlur={true}
>
@ -169,6 +170,7 @@ const SelectAmountScreen = () => {
return values.customAmount ? parseFloat(values.customAmount) : 0;
};
// Improved button state logic
const isPayButtonEnabled = () => {
if (values.paymentType === "due") return true;
@ -190,13 +192,17 @@ const SelectAmountScreen = () => {
<KeyboardAvoidingView
style={styles.container}
behavior={Platform.OS === "ios" ? "padding" : "height"}
keyboardVerticalOffset={Platform.OS === "ios" ? 0 : 20}
// Improved keyboard offset
keyboardVerticalOffset={Platform.OS === "ios" ? 88 : 0}
>
<Header title="Select Amount" showBackButton={true} />
<ScrollView
style={styles.content}
showsVerticalScrollIndicator={false}
// Add keyboard dismiss on scroll
keyboardShouldPersistTaps="handled"
contentContainerStyle={styles.scrollContent}
>
<View style={styles.selectAmountContainer}>
<TouchableOpacity
@ -262,7 +268,14 @@ const SelectAmountScreen = () => {
]}
value={values.customAmount}
onChangeText={(text) => {
handleChange("customAmount")(text);
const numericText = text.replace(/[^0-9.]/g, "");
const parts = numericText.split(".");
const formattedText =
parts.length > 2
? parts[0] + "." + parts.slice(1).join("")
: numericText;
setFieldValue("customAmount", formattedText, true);
setFieldValue("paymentType", "custom");
}}
onBlur={handleBlur("customAmount")}
@ -270,6 +283,8 @@ const SelectAmountScreen = () => {
placeholderTextColor="#94A3B8"
keyboardType="numeric"
onFocus={() => setFieldValue("paymentType", "custom")}
// Add return key handling
returnKeyType="done"
/>
<View style={styles.helperContainer}>
<Text
@ -345,6 +360,10 @@ const styles = StyleSheet.create({
flex: 1,
paddingHorizontal: 16,
},
// Add scroll content style for better keyboard handling
scrollContent: {
paddingBottom: 20,
},
selectAmountContainer: {
paddingTop: 16,
gap: 16,

8
assets/icons/cancel.svg Normal file
View File

@ -0,0 +1,8 @@
<svg width="81" height="81" viewBox="0 0 81 81" fill="none" xmlns="http://www.w3.org/2000/svg">
<mask id="mask0_709_4789" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="0" y="0" width="81" height="81">
<rect x="0.09375" y="0.123047" width="80" height="80" fill="#D9D9D9"/>
</mask>
<g mask="url(#mask0_709_4789)">
<path d="M40.0931 44.7897L49.7598 54.4564C50.3709 55.0675 51.1487 55.3731 52.0931 55.3731C53.0375 55.3731 53.8153 55.0675 54.4264 54.4564C55.0375 53.8453 55.3431 53.0675 55.3431 52.1231C55.3431 51.1786 55.0375 50.4008 54.4264 49.7897L44.7598 40.1231L54.4264 30.4564C55.0375 29.8453 55.3431 29.0675 55.3431 28.1231C55.3431 27.1786 55.0375 26.4008 54.4264 25.7897C53.8153 25.1786 53.0375 24.8731 52.0931 24.8731C51.1487 24.8731 50.3709 25.1786 49.7598 25.7897L40.0931 35.4564L30.4264 25.7897C29.8153 25.1786 29.0375 24.8731 28.0931 24.8731C27.1487 24.8731 26.3709 25.1786 25.7598 25.7897C25.1487 26.4008 24.8431 27.1786 24.8431 28.1231C24.8431 29.0675 25.1487 29.8453 25.7598 30.4564L35.4264 40.1231L25.7598 49.7897C25.1487 50.4008 24.8431 51.1786 24.8431 52.1231C24.8431 53.0675 25.1487 53.8453 25.7598 54.4564C26.3709 55.0675 27.1487 55.3731 28.0931 55.3731C29.0375 55.3731 29.8153 55.0675 30.4264 54.4564L40.0931 44.7897ZM40.0931 73.4564C35.482 73.4564 31.1487 72.5814 27.0931 70.8314C23.0375 69.0814 19.5098 66.7064 16.5098 63.7064C13.5098 60.7064 11.1348 57.1786 9.38477 53.1231C7.63477 49.0675 6.75977 44.7342 6.75977 40.1231C6.75977 35.512 7.63477 31.1786 9.38477 27.1231C11.1348 23.0675 13.5098 19.5397 16.5098 16.5397C19.5098 13.5397 23.0375 11.1647 27.0931 9.41473C31.1487 7.66473 35.482 6.78973 40.0931 6.78973C44.7042 6.78973 49.0375 7.66473 53.0931 9.41473C57.1487 11.1647 60.6764 13.5397 63.6764 16.5397C66.6764 19.5397 69.0514 23.0675 70.8014 27.1231C72.5514 31.1786 73.4264 35.512 73.4264 40.1231C73.4264 44.7342 72.5514 49.0675 70.8014 53.1231C69.0514 57.1786 66.6764 60.7064 63.6764 63.7064C60.6764 66.7064 57.1487 69.0814 53.0931 70.8314C49.0375 72.5814 44.7042 73.4564 40.0931 73.4564Z" fill="#D51D10"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

8
assets/icons/pending.svg Normal file
View File

@ -0,0 +1,8 @@
<svg width="80" height="81" viewBox="0 0 80 81" fill="none" xmlns="http://www.w3.org/2000/svg">
<mask id="mask0_709_4861" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="0" y="0" width="80" height="81">
<rect y="0.123047" width="80" height="80" fill="#D9D9D9"/>
</mask>
<g mask="url(#mask0_709_4861)">
<path d="M39.9993 56.7897C40.9438 56.7897 41.7355 56.4703 42.3744 55.8314C43.0132 55.1925 43.3327 54.4008 43.3327 53.4564C43.3327 52.512 43.0132 51.7203 42.3744 51.0814C41.7355 50.4425 40.9438 50.1231 39.9993 50.1231C39.0549 50.1231 38.2632 50.4425 37.6243 51.0814C36.9855 51.7203 36.666 52.512 36.666 53.4564C36.666 54.4008 36.9855 55.1925 37.6243 55.8314C38.2632 56.4703 39.0549 56.7897 39.9993 56.7897ZM39.9993 43.4564C40.9438 43.4564 41.7355 43.137 42.3744 42.4981C43.0132 41.8592 43.3327 41.0675 43.3327 40.1231V26.7897C43.3327 25.8453 43.0132 25.0536 42.3744 24.4147C41.7355 23.7758 40.9438 23.4564 39.9993 23.4564C39.0549 23.4564 38.2632 23.7758 37.6243 24.4147C36.9855 25.0536 36.666 25.8453 36.666 26.7897V40.1231C36.666 41.0675 36.9855 41.8592 37.6243 42.4981C38.2632 43.137 39.0549 43.4564 39.9993 43.4564ZM39.9993 73.4564C35.3882 73.4564 31.0549 72.5814 26.9993 70.8314C22.9438 69.0814 19.416 66.7064 16.416 63.7064C13.416 60.7064 11.041 57.1786 9.29102 53.1231C7.54102 49.0675 6.66602 44.7342 6.66602 40.1231C6.66602 35.512 7.54102 31.1786 9.29102 27.1231C11.041 23.0675 13.416 19.5397 16.416 16.5397C19.416 13.5397 22.9438 11.1647 26.9993 9.41473C31.0549 7.66473 35.3882 6.78973 39.9993 6.78973C44.6105 6.78973 48.9438 7.66473 52.9994 9.41473C57.0549 11.1647 60.5827 13.5397 63.5827 16.5397C66.5827 19.5397 68.9577 23.0675 70.7077 27.1231C72.4577 31.1786 73.3327 35.512 73.3327 40.1231C73.3327 44.7342 72.4577 49.0675 70.7077 53.1231C68.9577 57.1786 66.5827 60.7064 63.5827 63.7064C60.5827 66.7064 57.0549 69.0814 52.9994 70.8314C48.9438 72.5814 44.6105 73.4564 39.9993 73.4564Z" fill="#FF7B00"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

View File

@ -15,9 +15,11 @@ export default ({
method,
status,
}: PaymentHistoryCardProps) => {
//failure, confirmed, pending
console.log(status, "payment Status");
const getStatusStyle = (status: string) => {
switch (status.toLowerCase()) {
case "failed":
case "failure":
return {
backgroundColor: "#FDE8E7",
color: "#D51C10",
@ -27,7 +29,7 @@ export default ({
backgroundColor: "#FFF0E3",
color: "#8E4400",
};
case "completed":
case "confirmed":
return {
backgroundColor: "#E8F5E8",
color: "#2D7D32",
@ -52,7 +54,7 @@ export default ({
<Text style={styles.paymentDetails}>
{time} {method}
</Text>
{status && (
{status && status !== "confirmed" && (
<View
style={[
styles.statusBadge,
@ -124,7 +126,6 @@ const styles = StyleSheet.create({
marginTop: 2, // slight vertical offset from Figma y=2
},
statusBadge: {
width: 63,
height: 20,
borderRadius: 4,
justifyContent: "center",

View File

@ -29,7 +29,7 @@ const Header: React.FC<HeaderProps> = ({
};
return (
<View style={[styles.container, { paddingTop: insets.top }]}>
<View style={[styles.container, { marginTop: insets.top }]}>
<View style={styles.leftContainer}>
{showBackButton && (
<TouchableOpacity style={styles.backButton} onPress={handleBack}>

View File

@ -10,10 +10,15 @@ import { MaterialIcons } from "@expo/vector-icons";
interface PaymentDueCardProps {
label: string;
amount: string;
amount: number;
onPress: () => void;
}
const formatAmount = (amount: number | null) => {
if (!amount) return "₹0";
return `${amount.toLocaleString("en-IN")}`;
};
const PaymentDueCard: React.FC<PaymentDueCardProps> = ({
label,
amount,
@ -27,7 +32,7 @@ const PaymentDueCard: React.FC<PaymentDueCardProps> = ({
</View>
<View style={styles.textGroup}>
<Text style={styles.label}>{label}</Text>
<Text style={styles.amount}>{amount}</Text>
<Text style={styles.amount}>{formatAmount(amount)}</Text>
</View>
</View>
<TouchableOpacity

View File

@ -8,6 +8,7 @@ import {
} from "react-native";
import Feather from "../../assets/icons/chevron_rightside.svg";
import { ALERT_STYLES } from "@/constants/config";
import { Href, useRouter } from "expo-router";
type AlertType = "info" | "warning" | "danger";
@ -15,19 +16,30 @@ interface AlertCardProps {
type: AlertType;
message: string;
subMessage?: string;
redirectPath: Href;
}
const AlertCard: React.FC<AlertCardProps> = ({ type, message, subMessage }) => {
const AlertCard: React.FC<AlertCardProps> = ({
type,
message,
subMessage,
redirectPath,
}) => {
const style = ALERT_STYLES[type];
const router = useRouter();
const containerStyle: ViewStyle[] = [
styles.container,
{ backgroundColor: style.backgroundColor },
];
function handlePress() {
router.push(redirectPath);
}
return (
<TouchableOpacity
style={[containerStyle, { backgroundColor: style.backgroundColor }]}
activeOpacity={0.9}
onPress={() => handlePress()}
>
<View style={styles.leftContent}>
<style.icon />

View File

@ -187,4 +187,5 @@ export const payments = {
REGISTER_TRANSACTION_EMIT_EVENT_NAME: "register-transaction",
REGISTER_TRANSACTION_LISTEN_EVENT_NAME: "registration-ack",
PAYMENT_CONFIRMATION_EVENT_NAME: "register-transaction",
EMI_WARNING_DAYS_THRESHOLD: 14,
};

View File

@ -7,7 +7,7 @@ interface PaymentOrder {
id?: number; // optional if not always present
expiry_date?: string;
order_id: string;
ppayment_link?: string;
payment_link?: string;
qr_code_url?: string;
status: string; // "confirmed", "pending", etc.
transaction_id?: string;

View File

@ -33,18 +33,55 @@ interface Battery {
charger_uid: string;
}
interface PaymentSummary {
due_amount: number;
daysLeftToPayEmi: number;
daysLeftToPayEmiText: string;
regularServiceDueInDaysText: string;
}
interface UserState {
data: UserData | null;
paymentSummary: PaymentSummary | null;
loading: boolean;
error: string | null;
}
const initialState: UserState = {
data: null,
paymentSummary: null,
loading: false,
error: null,
};
export const getPaymentSummary = createAsyncThunk<PaymentSummary>(
"user/getPaymentSummary",
async (_, { rejectWithValue }) => {
try {
console.log("Fetching payment summary from API...");
const response = await api.get(`${BASE_URL}/api/v1/payments-summary`);
if (response.data.success) {
console.log("Payment summary fetched:", response.data);
return {
due_amount: response.data.due_amount,
daysLeftToPayEmi: response.data.daysLeftToPayEmi,
daysLeftToPayEmiText: response.data.daysLeftToPayEmiText,
regularServiceDueInDaysText:
response.data.regularServiceDueInDaysText || "",
};
} else {
return rejectWithValue("Failed to fetch payment summary");
}
} catch (error: any) {
const message =
error.response?.data?.message ||
error.message ||
"Something went wrong";
return rejectWithValue(message);
}
}
);
// 🔁 Thunk to get user details
export const getUserDetails = createAsyncThunk<UserData>(
"user/getUserDetails",
@ -79,6 +116,7 @@ const userSlice = createSlice({
state.data = null;
state.error = null;
state.loading = false;
state.paymentSummary = null;
},
setUserData: (state, action: PayloadAction<Partial<UserData>>) => {
if (state.data) {
@ -107,6 +145,20 @@ const userSlice = createSlice({
.addCase(getUserDetails.rejected, (state, action) => {
state.loading = false;
state.error = action.payload as string;
})
// Payment summary
.addCase(getPaymentSummary.pending, (state) => {
state.loading = true;
state.error = null;
})
.addCase(getPaymentSummary.fulfilled, (state, action) => {
state.loading = false;
state.paymentSummary = action.payload;
})
.addCase(getPaymentSummary.rejected, (state, action) => {
state.loading = false;
state.error = action.payload as string;
});
},
});

6
utils/Common.ts Normal file
View File

@ -0,0 +1,6 @@
export const displayValue = (value: any, formatter?: (val: any) => string) => {
if (value === null || value === undefined || value === "") {
return "--";
}
return formatter ? formatter(value) : value;
};