BaaS_Driver_Android_App/app/(tabs)/payments.tsx

907 lines
23 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

import {
useLayoutEffect,
useMemo,
useState,
useEffect,
useCallback,
} from "react";
import { useNavigation, useRouter } from "expo-router";
import {
Animated,
Easing,
NativeScrollEvent,
NativeSyntheticEvent,
Pressable,
ScrollView,
StyleSheet,
TouchableOpacity,
} from "react-native";
import { Text, View } from "react-native";
import { useSelector } from "react-redux";
import { RootState } from "@/store";
import ProfileImage from "@/components/home/Profile";
import { Overlay } from "@/components/common/Overlay";
import { useSnackbar } from "@/contexts/Snackbar";
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 {
setAdvanceBalance,
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";
import { useTranslation } from "react-i18next";
import { Image } from "expo-image";
export interface MyPlan {
no_of_emi: number;
total_amount: number;
down_payment: number;
emi_amount: number;
total_emi: number;
installment_paid: number;
current_amount: number;
}
interface EmiDetails {
due_amount: number;
total_amount_paid_in_current_cycle: number;
due_date: string;
status: string;
advance_balance: number;
pending_cycles: number;
total_pending_installments: number;
myPlain: MyPlan;
}
export interface EmiResponse {
success: boolean;
data: EmiDetails[];
}
interface PaymentHistoryItem {
id: number;
amount: string;
status: string;
created_at: string;
payment_mode: string[];
payment_date: string;
}
interface PaymentHistoryPagination {
total_records: number;
page_number: number;
page_size: number;
}
interface PaymentHistoryResponse {
success: boolean;
data: {
payments: PaymentHistoryItem[];
pagination: PaymentHistoryPagination;
};
}
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
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}`;
}
export default function PaymentsTabScreen() {
const navigation = useNavigation();
const { data } = useSelector((state: RootState) => state.user);
const router = useRouter();
const { showSnackbar } = useSnackbar();
const [emiDetails, setEmiDetails] = useState<EmiDetails | null>(null);
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[]>(
[]
);
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);
const [hasMorePages, setHasMorePages] = useState(true);
const [isLoadingMore, setIsLoadingMore] = useState(false);
const vehicle =
Array.isArray(data?.vehicles) && data.vehicles.length > 0
? data.vehicles[0]
: null;
const battery =
Array.isArray(data?.batteries) && data.batteries.length > 0
? data.batteries[0]
: null;
const model = vehicle?.model ?? "---";
const chasisNumber = vehicle?.chasis_number ?? "---";
const fetchEmiDetails = async () => {
try {
setIsLoading(true);
setEmiDetails(null);
setAdvanceBalance(null);
const response = await api.get(`/api/v1/emi-details`);
const result: EmiResponse = response.data;
if (result.success && result.data.length > 0) {
const details = result.data[0];
setEmiDetails(details);
dispatch(setDueAmount(details.due_amount));
dispatch(setMyPlan(details.myPlain));
dispatch(setAdvanceBalance(details.advance_balance));
} else {
showSnackbar("No EMI details found", "error");
}
} catch (error: any) {
if (error.message === "Network Error" || !error.response) {
showSnackbar(t("common.no-internet-connection"), "error");
} else {
showSnackbar(t("common.something-went-wrong"), "error");
}
console.error("Manual refresh failed", error);
} finally {
setIsLoading(false);
}
};
const handleRefresh = async () => {
try {
console.log("payments refresh");
setShowFullHistory(false);
setRefreshing(true);
startSpin();
await Promise.all([fetchEmiDetails(), fetchPaymentHistory(1, false)]);
console.log("Manual refresh complete");
} catch (error: any) {
if (error.message === "Network Error" || !error.response) {
showSnackbar(t("common.no-internet-connection"), "error");
} else {
showSnackbar(t("common.something-went-wrong"), "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({
headerStyle: {
backgroundColor: "#F3F5F8",
},
headerTitle: () => (
<View style={styles.headerTitleContainer}>
<View style={styles.logoContainer}>
<Image
source={require("../../assets/images/lio_logo.png")}
style={styles.logo}
/>
</View>
<View>
<Text style={styles.title}>{model}</Text>
<Text style={styles.subtitle}>{chasisNumber}</Text>
</View>
</View>
),
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={() => {
console.log("Support Pressed");
setIsSupportModalVisible(true);
}}
>
<CustomerCareIcon height={50} width={50} />
</Pressable>
<View>
<ProfileImage
username={data?.name || "--"}
onClick={() => router.push("/user/profile")}
textSize={20}
boxSize={40}
/>
</View>
</View>
),
});
}, [navigation, data, model, chasisNumber, refreshing]);
// Format currency
const formatCurrency = (amount: number) => {
return `${amount.toLocaleString()}`;
};
const handleViewAll = () => {
setShowFullHistory(true);
};
// Format date
const formatDate = (dateString: string) => {
try {
const date = new Date(dateString);
return date.toLocaleDateString("en-IN", {
day: "2-digit",
month: "short",
year: "numeric",
});
} catch {
return dateString;
}
};
// Get current month/year for header
const getCurrentMonthYear = () => {
const date = new Date();
return date.toLocaleDateString("en-US", {
month: "long",
year: "numeric",
});
};
const fetchPaymentHistory = async (
pageNumber: number = 1,
isLoadMore: boolean = false
) => {
try {
if (isLoadMore) {
setIsLoadingMore(true);
} else {
setIsHistoryLoading(true);
}
const response = await api.get(
`${BASE_URL}/api/v1/payment-history?page_number=${pageNumber}&page_size=10`
);
const result: PaymentHistoryResponse = response.data;
console.log("Payment History Response:", result);
if (result.success) {
const newPayments = result.data.payments;
if (isLoadMore) {
setPaymentHistory((prev) => [...prev, ...newPayments]);
} else {
setPaymentHistory(newPayments);
}
// Check if there are more pages
const totalPages = Math.ceil(
result.data.pagination.total_records /
result.data.pagination.page_size
);
setHasMorePages(pageNumber < totalPages);
setCurrentPage(pageNumber);
} else {
showSnackbar("No payment history found", "error");
}
} catch (error: any) {
if (error.message === "Network Error" || !error.response) {
showSnackbar(t("common.no-internet-connection"), "error");
} else {
showSnackbar(t("common.something-went-wrong"), "error");
}
console.error("Manual refresh failed", error);
} finally {
if (isLoadMore) {
setIsLoadingMore(false);
} else {
setIsHistoryLoading(false);
}
}
};
const handleLoadMore = () => {
if (hasMorePages && !isLoadingMore) {
fetchPaymentHistory(currentPage + 1, true);
}
};
useEffect(() => {
if (isEndReached && showFullHistory && hasMorePages && !isLoadingMore) {
handleLoadMore();
}
}, [isEndReached]);
const handleScroll = (event: NativeSyntheticEvent<NativeScrollEvent>) => {
const { layoutMeasurement, contentOffset, contentSize } = event.nativeEvent;
const paddingToBottom = 20;
const isAtBottom =
layoutMeasurement.height + contentOffset.y >=
contentSize.height - paddingToBottom;
setIsEndReached(isAtBottom);
};
const { t } = useTranslation();
return (
<>
<ScrollView
contentContainerStyle={{ paddingHorizontal: 16, paddingBottom: 125 }}
showsVerticalScrollIndicator={false}
onScroll={handleScroll}
scrollEventThrottle={16}
>
{/* Last EMI Details Card */}
<View style={styles.emiCard}>
{/* Header */}
<View style={styles.cardHeader}>
<Text style={styles.headerTitle}>
{t("payment.last-emi-details")}
</Text>
<Text style={styles.headerDate}>{getCurrentMonthYear()}</Text>
</View>
{/* Divider */}
<View style={styles.divider} />
{/* EMI Details Content */}
<View style={styles.cardContent}>
<View style={styles.detailRow}>
<Text style={styles.detailLabel}>{t("payment.amount-due")}</Text>
<Text style={styles.detailValue}>
{displayValue(emiDetails?.due_amount, formatCurrency)}
</Text>
</View>
<View style={styles.detailRow}>
<Text style={styles.detailLabel}>{t("payment.amount-paid")}</Text>
<Text style={styles.detailValue}>
{displayValue(
emiDetails?.total_amount_paid_in_current_cycle,
formatCurrency
)}
</Text>
</View>
<View style={styles.detailRow}>
<Text style={styles.detailLabel}>{t("payment.due-date")}</Text>
<Text style={styles.detailValue}>
{displayValue(emiDetails?.due_date)}
</Text>
</View>
<View style={styles.detailRow}>
<Text style={styles.detailLabel}>
{t("payment.payment-status")}
</Text>
{emiDetails?.status ? (
<StatusBadge
label={emiDetails.status}
type={emiDetails.status.toLowerCase()}
/>
) : (
<Text style={styles.detailValue}>--</Text>
)}
</View>
</View>
<View style={styles.divider} />
<View style={styles.buttonContainer}>
<TouchableOpacity
style={[
styles.primaryButton,
(isLoading || !emiDetails) && { opacity: 0.5 }, // dim if disabled
]}
onPress={() => router.push("/payments/selectAmount")}
disabled={isLoading || !emiDetails}
>
<Text style={styles.primaryButtonText}>
{t("payment.pay-emi")}
</Text>
</TouchableOpacity>
<TouchableOpacity
style={[
styles.tertiaryButton,
(isLoading || !emiDetails) && { opacity: 0.5 },
]}
onPress={() => router.push("/payments/myPlan")}
disabled={isLoading || !emiDetails}
>
<Text style={styles.tertiaryButtonText}>
{t("payment.view-plan")}
</Text>
</TouchableOpacity>
</View>
</View>
<View>
<Text style={styles.sectionTitle}>
{t("payment.payment-history")}
</Text>
<View style={styles.paymentHistoryContainer}>
{isHistoryLoading ? (
<View style={styles.historyLoadingContainer}>
<ActivityIndicator color="#00BE88" />
</View>
) : (
<>
{/* Show initial payments or all payments based on showFullHistory */}
{(showFullHistory
? paymentHistory
: paymentHistory.slice(0, 3)
).map((payment) => (
<PaymentHistoryCard
id={payment.id}
key={payment.id}
date={formatPaymentDate(payment.payment_date)}
amount={formatCurrency(parseFloat(payment.amount))}
time={formatPaymentTime(payment.payment_date)}
method={payment.payment_mode.join(", ")}
status={payment.status}
/>
))}
{!showFullHistory && paymentHistory.length > 2 && (
<TouchableOpacity
style={styles.viewAllButton}
onPress={handleViewAll}
>
<Text style={styles.viewAllText}>
{t("payment.view-all")}
</Text>
<Text style={styles.chevron}></Text>
</TouchableOpacity>
)}
{isLoadingMore && (
<View style={styles.loadingContainer}>
<Text style={styles.loadingText}>
<ActivityIndicator color="#00BE88" />
</Text>
</View>
)}
{showFullHistory &&
!hasMorePages &&
paymentHistory.length > 0 && (
<View style={styles.noMoreDataContainer}>
<Text style={styles.noMoreDataText}>
No more payments to show
</Text>
</View>
)}
{/* Empty state */}
{paymentHistory.length === 0 && !isHistoryLoading && (
<View style={styles.noDataContainer}>
<Text style={styles.noDataText}>
No payment history found
</Text>
</View>
)}
</>
)}
</View>
</View>
</ScrollView>
<CustomerSupport
visible={isSupportModalVisible}
onClose={() => setIsSupportModalVisible(false)}
/>
</>
);
}
const StatusBadge = ({
label,
type,
}: {
label: string | undefined;
type: string | undefined;
}) => {
if (!label || !type) return "--";
const getBadgeStyle = (type: string) => {
switch (type) {
case "pending":
return {
backgroundColor: "#FFF0E3",
color: "#8E4400",
};
case "completed":
return {
backgroundColor: "#E8F5E8",
color: "#2D7D32",
};
default:
return {
backgroundColor: "#E8F5E8",
color: "#2D7D32",
};
}
};
const badgeStyle = getBadgeStyle(type);
return (
<View
style={[styles.badge, { backgroundColor: badgeStyle.backgroundColor }]}
>
<Text style={[styles.badgeText, { color: badgeStyle.color }]}>
{label.charAt(0).toUpperCase() + label.slice(1)}
</Text>
</View>
);
};
const styles = StyleSheet.create({
logo: {
width: "100%",
height: "100%",
resizeMode: "contain",
},
logoContainer: {
padding: 8,
borderRadius: 44 / 2, // make it perfectly round
borderWidth: 2,
borderColor: "#E5E9F0",
width: 44,
height: 44,
alignItems: "center",
justifyContent: "center",
overflow: "hidden", // ensures image doesn't overflow
},
loadingContainer: {
padding: 16,
alignItems: "center",
justifyContent: "center",
},
container: {
flex: 1,
alignItems: "center",
justifyContent: "center",
},
title: {
fontSize: 14,
color: "#6B7280",
fontWeight: "500",
},
separator: {
marginVertical: 30,
height: 1,
width: "80%",
},
headerTitleContainer: {
flexDirection: "row",
backgroundColor: "#F3F5F8",
gap: 8,
},
subtitle: {
fontSize: 18,
color: "#111827",
fontWeight: "700",
},
rightContainer: {
flexDirection: "row",
alignItems: "center",
paddingRight: 16,
gap: 8,
backgroundColor: "#F3F5F8",
},
supportButton: {
backgroundColor: "#F3F5F8",
},
scrollContainer: {
flex: 1,
paddingHorizontal: 16,
paddingBottom: 16,
width: "100%",
},
// EMI Card Styles
emiCard: {
backgroundColor: "#FCFCFC",
borderRadius: 8,
padding: 12,
marginBottom: 36,
},
cardHeader: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "flex-end",
marginBottom: 12,
},
headerTitle: {
fontSize: 14,
fontWeight: "600",
color: "#252A34",
},
headerDate: {
fontSize: 14,
fontWeight: "500",
color: "#565C70",
},
divider: {
height: 1,
backgroundColor: "#E5E9F0",
marginBottom: 12,
},
cardContent: {
gap: 8,
marginBottom: 12,
},
detailRow: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
minHeight: 20,
},
detailLabel: {
fontSize: 14,
color: "#252A34",
flex: 1,
},
detailValue: {
fontSize: 14,
fontWeight: "600",
color: "#252A34",
},
// Badge Styles
badge: {
paddingHorizontal: 8,
paddingVertical: 2,
borderRadius: 4,
},
badgeText: {
fontSize: 12,
fontWeight: "500",
},
// Button Styles
buttonContainer: {
gap: 8,
},
primaryButton: {
backgroundColor: "#008761",
borderRadius: 4,
paddingVertical: 8,
paddingHorizontal: 16,
alignItems: "center",
minHeight: 40,
justifyContent: "center",
},
primaryButtonText: {
color: "#FCFCFC",
fontSize: 14,
fontWeight: "500",
},
tertiaryButton: {
borderRadius: 4,
paddingVertical: 8,
alignItems: "center",
minHeight: 36,
justifyContent: "center",
},
tertiaryButtonText: {
color: "#006C4D",
fontSize: 14,
fontWeight: "500",
},
// Plan Details Styles
planDetailsSection: {
gap: 8,
marginBottom: 20,
},
planCard: {
backgroundColor: "#FCFCFC",
borderRadius: 8,
padding: 12,
},
sectionTitle: {
fontSize: 14,
lineHeight: 20,
color: "#252A34", // rgb(37,42,51) from design
marginBottom: 8,
fontWeight: "600",
},
paymentHistoryContainer: {
flexDirection: "column",
gap: 16,
},
historyLoadingContainer: {
height: 312,
justifyContent: "center",
alignItems: "center",
},
loadingText: {
fontFamily: "Inter-Regular",
fontSize: 14,
color: "#252A33",
},
noDataContainer: {
height: 312,
justifyContent: "center",
alignItems: "center",
},
noDataText: {
fontFamily: "Inter-Regular",
fontSize: 14,
color: "#252A33",
},
noMoreDataContainer: {
marginTop: 16,
justifyContent: "center",
alignItems: "center",
},
noMoreDataText: {
fontFamily: "Inter-Regular",
fontSize: 12,
color: "#5B6478", // rgb(91,100,120) muted text
},
paymentCard: {
width: 328,
height: 76,
backgroundColor: "#FCFCFC", // #FCFCFC or #FBFBFB as in design (near white)
borderRadius: 8,
paddingVertical: 12,
paddingHorizontal: 12,
marginBottom: 16,
justifyContent: "space-between",
// flexDirection: "column" by default
},
paymentCardTopRow: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
height: 20,
},
paymentDate: {
fontFamily: "Inter-Regular",
fontSize: 14,
color: "#252A33",
},
paymentAmount: {
fontFamily: "Inter-SemiBold",
fontSize: 14,
color: "#252A33",
},
paymentCardBottomRow: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
height: 20,
},
paymentTimeMethod: {
fontFamily: "Inter-Regular",
fontSize: 12,
color: "#56607A", // rgb(86,96,122)
},
paymentStatusBadge: {
borderRadius: 4,
paddingVertical: 2,
paddingHorizontal: 8,
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
},
paymentStatusLabel: {
fontFamily: "Inter-Medium",
fontSize: 12,
textAlign: "center",
},
// Status colors — you can override backgroundColor and color dynamically based on status
statusPending: {
backgroundColor: "#FFF0E5", // approx. (1, 0.941, 0.89)
color: "#803F0C", // approx. (0.5, 0.27, 0.047)
},
statusFailed: {
backgroundColor: "#FDDDD7", // approx. (0.99, 0.91, 0.9)
color: "#D6290A", // approx. (0.83, 0.11, 0.06)
},
statusSuccess: {
backgroundColor: "#E6F4EA", // light green example
color: "#0B8235",
},
viewAllButton: {
flexDirection: "row",
justifyContent: "center",
alignItems: "center",
paddingVertical: 8,
paddingHorizontal: 16,
borderRadius: 4,
},
viewAllText: {
fontFamily: "Inter-Medium",
fontSize: 14,
color: "#007958", // greenish text color
},
chevron: {
fontSize: 18,
color: "#007958",
marginLeft: 4,
},
loadMoreButton: {
paddingVertical: 8,
paddingHorizontal: 16,
borderRadius: 4,
backgroundColor: "#007958",
alignSelf: "center",
},
loadMoreText: {
fontFamily: "Inter-Medium",
fontSize: 14,
color: "#FFFFFF",
},
});