Amba-Agent-App/app/(root)/(tabs)/index.tsx
2026-01-16 00:22:35 +03:00

645 lines
24 KiB
TypeScript

import React, { useState, useRef, useEffect } from "react";
import {
View,
Text,
ScrollView,
TouchableOpacity,
Pressable,
FlatList,
Platform,
ImageBackground,
Image,
} from "react-native";
import { Button } from "~/components/ui/button";
import ProfileCard from "~/components/ui/profileCard";
import ContactModal from "~/components/ui/contactModal";
import ProtectedRoute from "~/components/other/protectedRoute";
import { useAuthWithProfile } from "~/lib/hooks/useAuthWithProfile";
import {
Users,
AlertCircle,
BellIcon,
Plus,
ScanBarcode,
LucideScan,
ScanIcon,
ScanBarcodeIcon,
ScanLine,
} from "lucide-react-native";
import { Link, router, useFocusEffect } from "expo-router";
import { ROUTES } from "~/lib/routes";
import { Contact, useContactsStore } from "~/lib/stores";
import { useTransactions } from "~/lib/hooks/useTransactions";
import ScreenWrapper from "~/components/ui/ScreenWrapper";
import { Icons } from "~/assets/icons";
import Skeleton from "~/components/ui/skeleton";
import { getAuthInstance } from "~/lib/firebase";
import ModalToast from "~/components/ui/toast";
import { useTranslation } from "react-i18next";
import { useSafeAreaInsets } from "react-native-safe-area-context";
// Contacts are only available on native platforms
const isContactsSupported = Platform.OS !== "web";
const UPCOMING_REMINDERS = [
{
id: "rem-1",
clientName: "Abebe Kebede",
datetimeLabel: "Today · 4:00 PM",
status: "Upcoming",
},
{
id: "rem-2",
clientName: "Sara Alemu",
datetimeLabel: "Tomorrow · 9:30 AM",
status: "Upcoming",
},
{
id: "rem-3",
clientName: "Hope Community Fund",
datetimeLabel: "Fri, 12 Jan · 2:15 PM",
status: "Scheduled",
},
];
const EXCHANGE_RATES = [
{ code: "USD", name: "US Dollar", rateToETB: 57.2 },
{ code: "AUD", name: "Australian Dollar", rateToETB: 38.4 },
{ code: "EUR", name: "Euro", rateToETB: 62.9 },
{ code: "GBP", name: "British Pound", rateToETB: 73.5 },
];
const getHomeTxStatusPillClasses = (status: string) => {
switch (status) {
case "completed":
return "bg-emerald-100 text-emerald-700";
case "pending":
return "bg-yellow-100 text-yellow-700";
case "failed":
default:
return "bg-red-100 text-red-700";
}
};
const formatHomeTxAmount = (amountInCents: number) => {
return `$${(amountInCents / 100).toFixed(2)}`;
};
const formatHomeTxTime = (createdAt: Date) => {
const d = createdAt instanceof Date ? createdAt : new Date(createdAt);
return d.toLocaleTimeString("en-US", {
hour: "2-digit",
minute: "2-digit",
});
};
const getHomeTxClientName = (tx: any) => {
if (tx.type === "send") return tx.recipientName || "Client";
if (tx.type === "receive") return tx.senderName || "Client";
if (tx.type === "add_cash") return "Card top up";
if (tx.type === "cash_out") return "Cash out";
return "Transaction";
};
const getHomeTxMethodLabel = (tx: any) => {
if (tx.type === "send") return "Telebirr";
if (tx.type === "receive") return "Wallet";
if (tx.type === "add_cash") return "Card";
if (tx.type === "cash_out") {
const provider = tx.bankProvider
? tx.bankProvider.charAt(0).toUpperCase() + tx.bankProvider.slice(1)
: "Bank";
return provider === "Telebirr" ? "Telebirr" : "Bank transfer";
}
return "Wallet";
};
export default function Home() {
const { user, wallet, walletLoading, walletError } = useAuthWithProfile();
const { t } = useTranslation();
const { profile, profileLoading } = useAuthWithProfile();
const insets = useSafeAreaInsets();
const fullName = profile?.fullName;
const firstName = fullName?.split(" ")[0];
const {
contacts,
loading: contactsLoading,
error: contactsError,
hasPermission,
requestPermission,
} = useContactsStore();
const {
transactions,
loading: transactionsLoading,
error: transactionsError,
} = useTransactions(user?.uid);
const scrollRef = useRef<ScrollView | null>(null);
const [toastVisible, setToastVisible] = useState(false);
const [toastTitle, setToastTitle] = useState("");
const [toastDescription, setToastDescription] = useState<string | undefined>(
undefined
);
const [toastVariant, setToastVariant] = useState<
"success" | "error" | "warning" | "info"
>("info");
const toastTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const displayName = user?.displayName || "";
const avatarSource = profile?.photoUrl
? { uri: profile.photoUrl }
: Icons.avatar;
// Log Firebase ID token when Home mounts (for debugging)
useEffect(() => {
const logIdToken = async () => {
try {
const auth = getAuthInstance();
const currentUser = auth.currentUser;
if (!currentUser) {
console.log("HOME: No current Firebase user, cannot get ID token");
return;
}
const idToken = await currentUser.getIdToken();
console.log("HOME SCREEN ID TOKEN:", idToken);
} catch (error) {
console.warn("HOME: Failed to get ID token:", error);
}
};
logIdToken();
}, []);
const showToast = (
title: string,
description?: string,
variant: "success" | "error" | "warning" | "info" = "info"
) => {
if (toastTimeoutRef.current) {
clearTimeout(toastTimeoutRef.current);
}
setToastTitle(title);
setToastDescription(description);
setToastVariant(variant);
setToastVisible(true);
toastTimeoutRef.current = setTimeout(() => {
setToastVisible(false);
toastTimeoutRef.current = null;
}, 2500);
};
// Modal state
const [selectedContact, setSelectedContact] = useState<Contact | null>(null);
const [modalVisible, setModalVisible] = useState(false);
const handleContactPress = (contact: Contact) => {
setSelectedContact(contact);
setModalVisible(true);
};
const handleCloseModal = () => {
setModalVisible(false);
setSelectedContact(null);
};
// Scroll to top whenever Home tab regains focus
useFocusEffect(
React.useCallback(() => {
if (scrollRef.current) {
scrollRef.current.scrollTo({ y: 0, animated: false });
}
}, [])
);
const handleCashOut = () => {
const balance = wallet?.balance;
if (balance === undefined) {
showToast(
t("home.cashoutErrorTitle"),
t("home.cashoutNoBalance"),
"error"
);
return;
}
if (balance < 1000) {
showToast(
t("home.cashoutErrorTitle"),
t("home.cashoutMinError"),
"warning"
);
return;
}
router.push(ROUTES.CASH_OUT);
};
const handleAddCash = () => {
router.push(ROUTES.ADD_CASH);
};
const handleAddCard = () => {
router.push(ROUTES.ADD_CARD);
};
return (
<ProtectedRoute>
<View>
<ScrollView
ref={scrollRef}
contentInsetAdjustmentBehavior="never"
showsVerticalScrollIndicator={false}
>
<View className="pb-24">
<View className="">
<View className="overflow-hidden">
<ImageBackground
borderRadius={0}
source={Icons.mainBG}
resizeMode="cover"
style={{
paddingVertical: 32,
paddingHorizontal: 20,
height: 250,
}}
imageStyle={{
borderBottomLeftRadius: 8,
borderBottomRightRadius: 8,
}}
>
<View className="flex flex-row items-center justify-between mb-8">
<View className="flex flex-row items-center justify-between -mt-4 w-full">
<View className="flex flex-row items-center">
<Text className="font-dmsans-semibold text-2xl text-white font-bold">
{t("components.topbar.greeting")}
</Text>
<Text className="font-dmsans-semibold text-2xl ml-1 font-bold text-[#FFB668]">
{profileLoading
? "..."
: firstName && firstName.length > 8
? firstName.substring(0, 8)
: firstName}
</Text>
</View>
<View className="flex flex-row items-center gap-2">
<Link href={ROUTES.NOTIFICATION} asChild>
<Pressable className="border border-dashed border-secondary rounded-full p-2">
<BellIcon color="#fff" size={24} />
</Pressable>
</Link>
{/* profile */}
<Link href={ROUTES.PROFILE} asChild>
<Pressable className="rounded-full overflow-hidden ml-3">
<Image
source={avatarSource}
style={[
{ width: 40, height: 40, borderRadius: 20 },
!profile?.photoUrl
? { tintColor: "#fff" }
: null,
]}
resizeMode="cover"
/>
</Pressable>
</Link>
</View>
</View>
</View>
<View className="items-center">
<Text className="text-white font-dmsans-semibold text-lg mb-1">
Available Credits
</Text>
<Text
style={{ lineHeight: 55 }}
className="text-white font-dmsans-bold text-6xl"
numberOfLines={1}
adjustsFontSizeToFit
minimumFontScale={0.5}
>
{walletLoading
? "..."
: wallet
? (wallet.balance / 100).toFixed(2)
: "0.00"}
</Text>
<Text className="text-white font-dmsans text-xs mt-2 opacity-90">
Balance usable for client payments
</Text>
{walletError && (
<Text className="text-red-300 font-dmsans-medium text-xs mt-2">
{walletError}
</Text>
)}
</View>
</ImageBackground>
</View>
{/* Action Pill */}
<View className="-mt-10 mb-4 px-4">
<View
className="bg-white rounded-3xl flex-row"
style={{
shadowColor: "#000",
shadowOpacity: 0.08,
shadowRadius: 40,
shadowOffset: { width: 4, height: 4 },
elevation: 6,
}}
>
{/* Add Cash */}
<TouchableOpacity
className="flex-1 items-center py-4"
onPress={() => router.push(ROUTES.ADD_CASH)}
>
<Image
source={Icons.addWallet}
style={{ width: 30, height: 30, marginBottom: 6 }}
resizeMode="contain"
/>
<Text className="font-dmsans-semibold text-base text-gray-800">
Add Cash
</Text>
</TouchableOpacity>
<View className="w-px bg-gray-200 my-3" />
{/* Pay */}
<TouchableOpacity
className="flex-1 items-center py-4"
onPress={() => router.push(ROUTES.SEND_OR_REQUEST_MONEY)}
>
<Image
source={Icons.cashout}
style={{ width: 30, height: 30, marginBottom: 6 }}
resizeMode="contain"
/>
<Text className="font-dmsans-semibold text-base text-gray-800">
Pay
</Text>
</TouchableOpacity>
<View className="w-px bg-gray-200 my-3" />
{/* Send notification */}
<TouchableOpacity
className="flex-1 items-center py-4"
onPress={() => router.push(ROUTES.SEND_NOTIFICATION)}
>
<BellIcon color="#FFB668" size={26} />
<Text className="font-dmsans-semibold text-base text-gray-800 text-center mt-1">
{"Send"}
{"\n"}
{"notification"}
</Text>
</TouchableOpacity>
</View>
</View>
</View>
<ScreenWrapper edges={[]}>
{/* Upcoming Reminders */}
<View className="flex flex-col px-5 pt-5 w-full">
<Text className="text-primary font-dmsans-bold text-base mb-3">
Upcoming Reminders
</Text>
{UPCOMING_REMINDERS.map((reminder) => (
<View
key={reminder.id}
className="flex-row items-center justify-between bg-gray-50 rounded-2xl px-4 py-3 mb-2"
>
<View className="flex-1 mr-3">
<Text className="text-sm font-dmsans-medium text-gray-900">
{reminder.clientName}
</Text>
<Text className="text-xs font-dmsans text-gray-500 mt-1">
{reminder.datetimeLabel}
</Text>
</View>
<View className="px-3 py-1 rounded-full bg-emerald-100">
<Text className="text-[11px] font-dmsans-medium text-emerald-700">
{reminder.status}
</Text>
</View>
</View>
))}
</View>
<View className="flex flex-col items-left px-5 py-5 w-full">
<View className="flex flex-row items-center w-full justify-between mb-2">
<Text className="text-primary font-dmsans-bold text-base">
Exchange Rates
</Text>
<View className="px-2 py-1 rounded-full bg-primary/10">
<Text className="text-[10px] font-dmsans-medium text-primary">
Live preview
</Text>
</View>
</View>
<View className="bg-primary/5 border border-primary/10 rounded-3xl p-3">
<View className="bg-white rounded-2xl overflow-hidden">
{EXCHANGE_RATES.map((item, index) => (
<View key={item.code}>
{index > 0 && (
<View className="h-px bg-gray-200 mx-4" />
)}
<View className="flex-row items-center justify-between px-4 py-3">
<View className="flex-row items-center">
<View className="w-7 h-7 rounded-full bg-primary/10 items-center justify-center mr-2">
<Text className="text-[11px] font-dmsans-bold text-primary">
{item.code}
</Text>
</View>
<View>
<Text className="text-[12px] font-dmsans-medium text-gray-900">
{item.name}
</Text>
<Text className="text-[10px] font-dmsans text-gray-500">
1 {item.code}
</Text>
</View>
</View>
<View className="items-end">
<Text className="text-base font-dmsans-bold text-gray-900">
{item.rateToETB.toFixed(2)}
</Text>
<Text className="text-[10px] font-dmsans text-gray-500">
{item.rateToETB.toFixed(2)} ETB
</Text>
</View>
</View>
</View>
))}
</View>
</View>
</View>
<View className="flex flex-col items-left px-5">
<Text className="text-primary font-dmsans-bold text-base mb-3">
{t("home.transactionsTitle")}
</Text>
{transactionsLoading ? (
<View className="flex flex-col gap-4 py-4">
{Array.from({ length: 5 }).map((_, index) => (
<View key={index} className="w-full">
<Skeleton width="100%" height={72} radius={12} />
</View>
))}
</View>
) : transactionsError ? (
<View className="flex items-center justify-center py-8">
<Text className="text-red-500 font-dmsans">
{t("home.transactionsError")}
</Text>
<Text className="text-gray-400 font-dmsans-medium text-sm mt-1 text-center px-4">
{transactionsError}
</Text>
</View>
) : transactions.length === 0 ? (
<View className="flex items-center justify-center py-8">
<Text className="text-gray-500 font-dmsans">
{t("home.transactionsNoTransactions")}
</Text>
<Text className="text-gray-400 font-dmsans-medium text-sm mt-1 text-center px-4">
{t("home.transactionsEmptySubtitle")}
</Text>
</View>
) : (
<View className="flex-col">
{transactions.slice(0, 5).map((transaction) => {
const pillClasses = getHomeTxStatusPillClasses(
transaction.status
);
const clientName = getHomeTxClientName(transaction);
const timeLabel = formatHomeTxTime(transaction.createdAt);
const methodLabel = getHomeTxMethodLabel(transaction);
const shortRef = transaction.id
? String(transaction.id).slice(-8)
: "";
return (
<TouchableOpacity
key={transaction.id}
activeOpacity={0.9}
onPress={() => {
router.push({
pathname: ROUTES.TRANSACTION_DETAIL,
params: {
transactionId: transaction.id,
amount: transaction.amount.toString(),
type: transaction.type,
recipientName: clientName,
date: transaction.createdAt.toISOString(),
status: transaction.status,
//@ts-ignore
note: transaction?.note || "",
fromHistory: "true",
},
});
}}
>
<View
className="bg-white rounded-3xl mb-3 border border-gray-100"
style={{
shadowColor: "#000",
shadowOpacity: 0.02,
shadowRadius: 40,
shadowOffset: { width: 0, height: 8 },
elevation: 2,
}}
>
<View className="px-4 py-3">
<View className="flex-row items-center justify-between mb-1.5">
<Text className="text-base font-dmsans-bold text-gray-900">
{formatHomeTxAmount(transaction.amount)}
</Text>
<View
className={`px-2 py-0.5 rounded-full ${pillClasses}`}
>
<Text className="text-[10px] font-dmsans-medium">
{transaction.status === "completed"
? "Success"
: transaction.status === "pending"
? "Pending"
: "Failed"}
</Text>
</View>
</View>
<View className="flex-row items-center justify-between mb-1">
<Text className="text-sm font-dmsans text-gray-800">
{clientName}
</Text>
<Text className="text-[11px] font-dmsans text-gray-400">
{timeLabel}
</Text>
</View>
<View className="flex-row items-center justify-between mt-1">
<View className="flex-row items-center">
<View className="w-7 h-7 rounded-full bg-primary/10 items-center justify-center mr-2">
<Image
source={Icons.bottomTransferIcon}
style={{
width: 16,
height: 16,
tintColor: "#0F7B4A",
}}
resizeMode="contain"
/>
</View>
<Text className="text-[11px] font-dmsans text-gray-500">
{methodLabel}
</Text>
</View>
{shortRef ? (
<Text className="text-[11px] font-dmsans text-gray-400">
{shortRef}
</Text>
) : null}
</View>
</View>
</View>
</TouchableOpacity>
);
})}
{transactions.length > 5 && (
<Pressable onPress={() => router.push(ROUTES.HISTORY)}>
<View className="items-center justify-center py-2">
<Text className="text-gray-400 font-dmsans-medium text-sm">
{t("home.transactionsMore", {
count: transactions.length - 5,
})}
</Text>
</View>
</Pressable>
)}
</View>
)}
</View>
</ScreenWrapper>
</View>
</ScrollView>
{/* Contact Modal */}
<ContactModal
visible={modalVisible}
contact={selectedContact}
onClose={handleCloseModal}
/>
<ModalToast
visible={toastVisible}
title={toastTitle}
description={toastDescription}
variant={toastVariant}
/>
</View>
</ProtectedRoute>
);
}