605 lines
20 KiB
TypeScript
605 lines
20 KiB
TypeScript
import React, { useState, useCallback, useMemo } from "react";
|
||
import {
|
||
View,
|
||
Text,
|
||
TouchableOpacity,
|
||
Image,
|
||
Platform,
|
||
Pressable,
|
||
} from "react-native";
|
||
import { FlashList } from "@shopify/flash-list";
|
||
import { Button } from "~/components/ui/button";
|
||
import {
|
||
LucideChevronRight,
|
||
LucideUser,
|
||
LucidePlus,
|
||
LucideSlidersHorizontal,
|
||
} from "lucide-react-native";
|
||
import { ROUTES } from "~/lib/routes";
|
||
import { router } from "expo-router";
|
||
import ContactModal from "~/components/ui/contactModal";
|
||
import { Recipient } from "~/lib/services/recipientService";
|
||
import { Contact, useContactsStore, useRecipientsStore } from "~/lib/stores";
|
||
import TopBar from "~/components/ui/topBar";
|
||
import { Input } from "~/components/ui/input";
|
||
import ScreenWrapper from "~/components/ui/ScreenWrapper";
|
||
import ModalToast from "~/components/ui/toast";
|
||
import { useTranslation } from "react-i18next";
|
||
import Skeleton from "~/components/ui/skeleton";
|
||
|
||
// Contacts are only available on native platforms
|
||
const isContactsSupported = Platform.OS !== "web";
|
||
|
||
// Helper function to create initials - moved outside component
|
||
const getInitials = (name: string) => {
|
||
return name
|
||
.split(" ")
|
||
.map((word) => word.charAt(0).toUpperCase())
|
||
.slice(0, 2)
|
||
.join("");
|
||
};
|
||
|
||
// Individual Contact Card Component - Memoized for performance
|
||
const ContactCard = React.memo(
|
||
({ contact, onPress }: { contact: Contact; onPress: () => void }) => {
|
||
const { t } = useTranslation();
|
||
const primaryPhone = useMemo(
|
||
() =>
|
||
contact.phoneNumbers?.find((phone) => phone.isPrimary) ||
|
||
contact.phoneNumbers?.[0],
|
||
[contact.phoneNumbers]
|
||
);
|
||
|
||
const initials = useMemo(() => getInitials(contact.name), [contact.name]);
|
||
|
||
return (
|
||
<TouchableOpacity
|
||
className="flex flex-row justify-between w-full items-center py-4 bg-green-50 rounded-md px-3"
|
||
onPress={onPress}
|
||
style={{ minHeight: 74 }} // Add explicit minHeight
|
||
>
|
||
<View className="flex flex-row space-x-3 items-center flex-1">
|
||
<View
|
||
className="rounded-full bg-secondary w-12 h-12 flex items-center justify-center"
|
||
style={{ width: 48, height: 48 }}
|
||
>
|
||
{contact.imageAvailable && contact.image ? (
|
||
<Image
|
||
source={{ uri: contact.image.uri }}
|
||
className="w-12 h-12 rounded-full"
|
||
style={{ width: 48, height: 48 }}
|
||
resizeMode="cover"
|
||
/>
|
||
) : (
|
||
<Text className="text-white font-dmsans-medium text-lg">
|
||
{initials}
|
||
</Text>
|
||
)}
|
||
</View>
|
||
<View className="w-2" />
|
||
<View className="flex space-y-1 flex-1">
|
||
<Text className="font-dmsans text-secondary" numberOfLines={1}>
|
||
{contact.name}
|
||
</Text>
|
||
<Text
|
||
className="font-dmsans-medium text-primary text-sm"
|
||
numberOfLines={1}
|
||
>
|
||
{primaryPhone?.number || t("listrecipient.contactNoPhone")}
|
||
</Text>
|
||
</View>
|
||
</View>
|
||
|
||
<View className="flex space-y-1">
|
||
<Button className="rounded-md">
|
||
<LucideChevronRight color="#FFB84D" size={20} />
|
||
</Button>
|
||
</View>
|
||
</TouchableOpacity>
|
||
);
|
||
},
|
||
(prevProps, nextProps) => {
|
||
// Custom comparison function for better performance
|
||
return (
|
||
prevProps.contact.id === nextProps.contact.id &&
|
||
prevProps.contact.name === nextProps.contact.name &&
|
||
prevProps.contact.imageAvailable === nextProps.contact.imageAvailable &&
|
||
prevProps.contact.image?.uri === nextProps.contact.image?.uri &&
|
||
prevProps.contact.phoneNumbers?.length ===
|
||
nextProps.contact.phoneNumbers?.length
|
||
);
|
||
}
|
||
);
|
||
|
||
// Individual Saved Recipient (Client) Card Component - Memoized for performance
|
||
const SavedRecipientCard = React.memo(
|
||
({
|
||
recipient,
|
||
onPress,
|
||
clientType,
|
||
accountsLabel,
|
||
nextPaymentLabel,
|
||
}: {
|
||
recipient: Recipient;
|
||
onPress: () => void;
|
||
clientType: "Individual" | "Business";
|
||
accountsLabel: string;
|
||
nextPaymentLabel?: string;
|
||
}) => {
|
||
const initials = useMemo(
|
||
() => getInitials(recipient.fullName),
|
||
[recipient.fullName]
|
||
);
|
||
|
||
return (
|
||
<TouchableOpacity
|
||
className="flex flex-row justify-between w-full items-center py-4 bg-[#F1FCF5] mb-3 rounded-md px-3"
|
||
onPress={onPress}
|
||
style={{ minHeight: 80 }}
|
||
>
|
||
<View className="flex flex-row space-x-3 items-center flex-1">
|
||
<View className="rounded-full bg-primary w-12 h-12 flex items-center justify-center">
|
||
<Text className="text-white font-dmsans-medium text-lg">
|
||
{initials}
|
||
</Text>
|
||
</View>
|
||
<View className="w-2" />
|
||
<View className="flex space-y-1 flex-1">
|
||
<View className="flex flex-row items-center justify-between">
|
||
<Text className="font-dmsans text-secondary" numberOfLines={1}>
|
||
{recipient.fullName}
|
||
</Text>
|
||
<View className="px-2 py-[2px] rounded-md bg-[#FFB668] ml-2">
|
||
<Text className="text-[11px] font-dmsans-medium text-white">
|
||
{clientType}
|
||
</Text>
|
||
</View>
|
||
</View>
|
||
<Text
|
||
className="font-dmsans-medium text-primary text-xs"
|
||
numberOfLines={1}
|
||
>
|
||
{accountsLabel}
|
||
</Text>
|
||
{nextPaymentLabel && (
|
||
<Text
|
||
className="font-dmsans text-gray-500 text-[11px]"
|
||
numberOfLines={1}
|
||
>
|
||
{nextPaymentLabel}
|
||
</Text>
|
||
)}
|
||
</View>
|
||
</View>
|
||
|
||
<View className="flex space-y-1 ml-4">
|
||
<LucideChevronRight color="#FFB84D" size={20} />
|
||
</View>
|
||
</TouchableOpacity>
|
||
);
|
||
},
|
||
(prevProps, nextProps) => {
|
||
// Custom comparison function for better performance
|
||
return (
|
||
prevProps.recipient.id === nextProps.recipient.id &&
|
||
prevProps.recipient.fullName === nextProps.recipient.fullName &&
|
||
prevProps.recipient.phoneNumber === nextProps.recipient.phoneNumber
|
||
);
|
||
}
|
||
);
|
||
|
||
export default function ListRecip() {
|
||
const { contacts, loading, error, hasPermission, requestPermission } =
|
||
useContactsStore();
|
||
const {
|
||
recipients,
|
||
loading: recipientsLoading,
|
||
error: recipientsError,
|
||
} = useRecipientsStore();
|
||
const { t } = useTranslation();
|
||
const [selectedContact, setSelectedContact] = useState<Contact | null>(null);
|
||
const [modalVisible, setModalVisible] = useState(false);
|
||
const [searchQuery, setSearchQuery] = React.useState("");
|
||
|
||
const [toastVisible, setToastVisible] = React.useState(false);
|
||
const [toastTitle, setToastTitle] = React.useState("");
|
||
const [toastDescription, setToastDescription] = React.useState<
|
||
string | undefined
|
||
>(undefined);
|
||
const [toastVariant, setToastVariant] = React.useState<
|
||
"success" | "error" | "warning" | "info"
|
||
>("info");
|
||
const toastTimeoutRef = React.useRef<ReturnType<typeof setTimeout> | null>(
|
||
null
|
||
);
|
||
|
||
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);
|
||
};
|
||
|
||
React.useEffect(() => {
|
||
return () => {
|
||
if (toastTimeoutRef.current) {
|
||
clearTimeout(toastTimeoutRef.current);
|
||
}
|
||
};
|
||
}, []);
|
||
|
||
React.useEffect(() => {
|
||
if (error) {
|
||
showToast(
|
||
t("listrecipient.toastErrorTitle"),
|
||
t("listrecipient.toastContactsError"),
|
||
"error"
|
||
);
|
||
} else if (recipientsError) {
|
||
showToast(
|
||
t("listrecipient.toastErrorTitle"),
|
||
t("listrecipient.toastRecipientsError"),
|
||
"error"
|
||
);
|
||
}
|
||
}, [error, recipientsError, t]);
|
||
|
||
const handleContactPress = useCallback((contact: Contact) => {
|
||
setSelectedContact(contact);
|
||
setModalVisible(true);
|
||
}, []);
|
||
|
||
const handleCloseModal = useCallback(() => {
|
||
setModalVisible(false);
|
||
setSelectedContact(null);
|
||
}, []);
|
||
|
||
const handleSendMoney = useCallback(
|
||
(contact: Contact) => {
|
||
// TODO: Navigate to send money screen with selected contact/recipient
|
||
if (__DEV__) {
|
||
console.log(
|
||
"Send money to:",
|
||
contact.name,
|
||
"Phone:",
|
||
contact.phoneNumbers?.[0]?.number
|
||
);
|
||
}
|
||
// Check if this is a saved recipient or device contact
|
||
const isSavedRecipient = recipients.some(
|
||
(recipient) => recipient.id === contact.id
|
||
);
|
||
if (isSavedRecipient) {
|
||
// router.push(`/sendorrequestmoney?recipientId=${contact.id}`);
|
||
if (__DEV__) {
|
||
console.log("This is a saved recipient");
|
||
}
|
||
} else {
|
||
// router.push(`/sendorrequestmoney?contactId=${contact.id}`);
|
||
if (__DEV__) {
|
||
console.log("This is a device contact");
|
||
}
|
||
}
|
||
},
|
||
[recipients]
|
||
);
|
||
|
||
const handleRecipientPress = useCallback((recipient: Recipient) => {
|
||
router.push({
|
||
pathname: ROUTES.RECIPIENT_DETAIL,
|
||
params: { recipientId: recipient.id },
|
||
});
|
||
}, []);
|
||
|
||
// Memoized renderItem function for FlashList performance
|
||
const renderContactItem = useCallback(
|
||
({ item }: { item: Contact }) => (
|
||
<ContactCard contact={item} onPress={() => handleContactPress(item)} />
|
||
),
|
||
[handleContactPress]
|
||
);
|
||
|
||
// Memoized renderItem for saved recipients
|
||
const renderRecipientItem = useCallback(
|
||
({ item, index }: { item: Recipient; index: number }) => {
|
||
// Simple UI-only client typing and schedule data
|
||
const lowerName = item.fullName.toLowerCase();
|
||
const isBusiness =
|
||
lowerName.includes("ltd") ||
|
||
lowerName.includes("plc") ||
|
||
lowerName.includes("inc") ||
|
||
lowerName.includes("company");
|
||
const clientType: "Individual" | "Business" = isBusiness
|
||
? "Business"
|
||
: "Individual";
|
||
const accountsCount = (index % 3) + 1; // 1–3 accounts, UI-only
|
||
const accountsLabel =
|
||
accountsCount === 1 ? "1 account" : `${accountsCount} accounts`;
|
||
// Give every 2nd/3rd client a dummy schedule label
|
||
let nextPaymentLabel: string | undefined;
|
||
if (index % 3 === 1) {
|
||
nextPaymentLabel = "Next payment: Every Monday · 10:00";
|
||
} else if (index % 3 === 2) {
|
||
nextPaymentLabel = "Next payment: Monthly, 1st · 09:00";
|
||
}
|
||
|
||
return (
|
||
<SavedRecipientCard
|
||
recipient={item}
|
||
onPress={() => handleRecipientPress(item)}
|
||
clientType={clientType}
|
||
accountsLabel={accountsLabel}
|
||
nextPaymentLabel={nextPaymentLabel}
|
||
/>
|
||
);
|
||
},
|
||
[handleRecipientPress]
|
||
);
|
||
|
||
// Memoized ItemSeparator component
|
||
const ItemSeparator = useCallback(() => <View className="h-2" />, []);
|
||
|
||
// Memoized keyExtractor
|
||
const keyExtractor = useCallback((item: Contact) => item.id, []);
|
||
|
||
// Memoized keyExtractor for recipients
|
||
const recipientKeyExtractor = useCallback((item: Recipient) => item.id, []);
|
||
|
||
const normalizedSearch = searchQuery.trim().toLowerCase();
|
||
|
||
const filteredContacts = useMemo(() => {
|
||
if (!normalizedSearch) return contacts;
|
||
|
||
return contacts.filter((contact) => {
|
||
const name = contact.name?.toLowerCase() ?? "";
|
||
const phones = (contact.phoneNumbers ?? [])
|
||
.map((p) => p.number)
|
||
.join(" ")
|
||
.toLowerCase();
|
||
|
||
return (
|
||
name.includes(normalizedSearch) || phones.includes(normalizedSearch)
|
||
);
|
||
});
|
||
}, [contacts, normalizedSearch]);
|
||
|
||
const filteredRecipients = useMemo(() => {
|
||
if (!normalizedSearch) return recipients;
|
||
|
||
return recipients.filter((recipient) => {
|
||
const name = recipient.fullName.toLowerCase();
|
||
const phone = recipient.phoneNumber.toLowerCase();
|
||
return (
|
||
name.includes(normalizedSearch) || phone.includes(normalizedSearch)
|
||
);
|
||
});
|
||
}, [recipients, normalizedSearch]);
|
||
|
||
const renderContent = () => {
|
||
if (loading) {
|
||
return (
|
||
<View className="space-y-3 py-2 w-full">
|
||
{Array.from({ length: 8 }).map((_, index) => (
|
||
<View
|
||
key={index}
|
||
className="flex flex-row items-center w-full py-2"
|
||
>
|
||
<Skeleton width={48} height={48} radius={24} />
|
||
<View style={{ width: 12 }} />
|
||
<View className="flex-1">
|
||
<Skeleton width="70%" height={14} radius={4} />
|
||
<View style={{ height: 8 }} />
|
||
<Skeleton width="40%" height={12} radius={4} />
|
||
</View>
|
||
</View>
|
||
))}
|
||
</View>
|
||
);
|
||
}
|
||
|
||
if (error) {
|
||
return (
|
||
<View className="flex items-center justify-center py-10">
|
||
<Text className="text-red-500 font-dmsans">
|
||
{t("listrecipient.contactsErrorTitle")}
|
||
</Text>
|
||
<Text className="text-gray-400 font-dmsans-medium text-sm mt-1 text-center px-4">
|
||
{error}
|
||
</Text>
|
||
</View>
|
||
);
|
||
}
|
||
|
||
if (!hasPermission) {
|
||
return (
|
||
<View className="flex items-center justify-center py-10">
|
||
<LucideUser color="#D1D5DB" size={48} />
|
||
<Text className="text-gray-500 font-dmsans mt-4">
|
||
{t("listrecipient.contactsPermissionTitle")}
|
||
</Text>
|
||
<Text className="text-gray-400 font-dmsans-medium text-sm mt-1 text-center px-4">
|
||
{t("listrecipient.contactsPermissionSubtitle")}
|
||
</Text>
|
||
<Button
|
||
className="bg-primary rounded-md mt-4 px-6"
|
||
onPress={requestPermission}
|
||
>
|
||
<Text className="text-white font-dmsans-medium">
|
||
{t("listrecipient.contactsPermissionButton")}
|
||
</Text>
|
||
</Button>
|
||
</View>
|
||
);
|
||
}
|
||
|
||
if (contacts.length === 0) {
|
||
return (
|
||
<View className="flex items-center justify-center py-10">
|
||
<LucideUser color="#D1D5DB" size={48} />
|
||
<Text className="text-gray-500 font-dmsans mt-4">
|
||
{t("listrecipient.contactsEmptyTitle")}
|
||
</Text>
|
||
<Text className="text-gray-400 font-dmsans-medium text-sm mt-1 text-center px-4">
|
||
{t("listrecipient.contactsEmptySubtitle")}
|
||
</Text>
|
||
</View>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<FlashList
|
||
data={filteredContacts}
|
||
keyExtractor={keyExtractor}
|
||
renderItem={renderContactItem}
|
||
// @ts-expect-error - estimatedItemSize is valid prop but types may be incomplete
|
||
estimatedItemSize={82} // Increased: 74 (item) + 8 (separator) = 82
|
||
ItemSeparatorComponent={ItemSeparator}
|
||
contentContainerStyle={{ paddingBottom: 16 }}
|
||
drawDistance={500} // Pre-render items 500px outside viewport
|
||
/>
|
||
);
|
||
};
|
||
|
||
return (
|
||
<ScreenWrapper edges={["bottom"]}>
|
||
<TopBar />
|
||
<View className="flex flex-col space-y-1 px-5 py-5 items-start">
|
||
<Text className="text-xl font-dmsans text-primary">
|
||
{t("listrecipient.title")}
|
||
</Text>
|
||
</View>
|
||
<View className="flex-1 flex flex-col items-left px-5 w-full">
|
||
<View className="w-full mb-4">
|
||
<Input
|
||
value={searchQuery}
|
||
onChangeText={setSearchQuery}
|
||
placeholderText={t("listrecipient.searchPlaceholder")}
|
||
containerClassName="w-full"
|
||
borderClassName="border-[#D9DBE9] bg-white"
|
||
placeholderColor="#7E7E7E"
|
||
textClassName="text-[#000] text-sm"
|
||
rightIcon={<LucideSlidersHorizontal color="#9CA3AF" size={18} />}
|
||
/>
|
||
</View>
|
||
|
||
{/* Add Recipient Button */}
|
||
<View className="flex flex-row items-center space-x-2 mb-6 w-full">
|
||
<Button
|
||
className="flex flex-row items-center space-x-2 bg-primary rounded-md p-3 w-full"
|
||
onPress={() => router.push(ROUTES.ADD_RECIPIENT)}
|
||
>
|
||
<LucidePlus className="w-[20px] h-[20px]" color="#FFB84D" />
|
||
<View className="w-2" />
|
||
<Text className="text-white text-base font-dmsans-medium">
|
||
{t("listrecipient.addButton")}
|
||
</Text>
|
||
</Button>
|
||
</View>
|
||
|
||
{/* Saved Recipients List */}
|
||
<View className="flex flex-col w-full mb-8">
|
||
<Text className="text-lg font-dmsans-medium text-gray-800 mb-4">
|
||
{t("listrecipient.savedRecipientsTitle", {
|
||
count: recipients.length,
|
||
})}
|
||
</Text>
|
||
{recipientsLoading ? (
|
||
<View className="space-y-3 py-2 w-full">
|
||
{Array.from({ length: 4 }).map((_, index) => (
|
||
<View
|
||
key={index}
|
||
className="flex flex-row items-center w-full py-2"
|
||
>
|
||
<Skeleton width={48} height={48} radius={24} />
|
||
<View style={{ width: 12 }} />
|
||
<View className="flex-1">
|
||
<Skeleton width="65%" height={14} radius={4} />
|
||
<View style={{ height: 8 }} />
|
||
<Skeleton width="35%" height={12} radius={4} />
|
||
</View>
|
||
</View>
|
||
))}
|
||
</View>
|
||
) : recipientsError ? (
|
||
<View className="flex items-center justify-center py-4">
|
||
<Text className="text-red-500 font-dmsans">
|
||
{t("listrecipient.savedRecipientsError")}
|
||
</Text>
|
||
</View>
|
||
) : recipients.length === 0 ? (
|
||
<View className="flex items-center justify-center py-4">
|
||
<Text className="text-gray-500 font-dmsans">
|
||
{t("listrecipient.savedRecipientsEmpty")}
|
||
</Text>
|
||
</View>
|
||
) : (
|
||
<View className="space-y-2">
|
||
{filteredRecipients.map((recipient, index) => {
|
||
const lowerName = recipient.fullName.toLowerCase();
|
||
const isBusiness =
|
||
lowerName.includes("ltd") ||
|
||
lowerName.includes("plc") ||
|
||
lowerName.includes("inc") ||
|
||
lowerName.includes("company");
|
||
const clientType: "Individual" | "Business" = isBusiness
|
||
? "Business"
|
||
: "Individual";
|
||
const accountsCount = (index % 3) + 1;
|
||
const accountsLabel =
|
||
accountsCount === 1
|
||
? "1 account"
|
||
: `${accountsCount} accounts`;
|
||
let nextPaymentLabel: string | undefined;
|
||
if (index % 3 === 1) {
|
||
nextPaymentLabel = "Next payment: Every Monday · 10:00";
|
||
} else if (index % 3 === 2) {
|
||
nextPaymentLabel = "Next payment: Monthly, 1st · 09:00";
|
||
}
|
||
|
||
return (
|
||
<SavedRecipientCard
|
||
key={recipient.id}
|
||
recipient={recipient}
|
||
onPress={() => handleRecipientPress(recipient)}
|
||
clientType={clientType}
|
||
accountsLabel={accountsLabel}
|
||
nextPaymentLabel={nextPaymentLabel}
|
||
/>
|
||
);
|
||
})}
|
||
</View>
|
||
)}
|
||
</View>
|
||
|
||
{/* Contacts list hidden for Agent app */}
|
||
<View className="h-20" />
|
||
</View>
|
||
{/* Contact Modal */}
|
||
<ContactModal
|
||
visible={modalVisible}
|
||
contact={selectedContact}
|
||
onClose={handleCloseModal}
|
||
/>
|
||
<ModalToast
|
||
visible={toastVisible}
|
||
title={toastTitle}
|
||
description={toastDescription}
|
||
variant={toastVariant}
|
||
/>
|
||
</ScreenWrapper>
|
||
);
|
||
}
|