645 lines
24 KiB
TypeScript
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>
|
|
);
|
|
}
|