501 lines
16 KiB
TypeScript
501 lines
16 KiB
TypeScript
import React, {
|
||
useState,
|
||
useEffect,
|
||
useMemo,
|
||
useCallback,
|
||
useRef,
|
||
} from "react";
|
||
import {
|
||
View,
|
||
Text,
|
||
ScrollView,
|
||
TouchableOpacity,
|
||
FlatList,
|
||
} from "react-native";
|
||
import { Input } from "~/components/ui/input";
|
||
import { Button } from "~/components/ui/button";
|
||
import { LucideUser } from "lucide-react-native";
|
||
import BackButton from "~/components/ui/backButton";
|
||
import { useContactsStore, useRecipientsStore } from "~/lib/stores";
|
||
import { useLocalSearchParams, router } from "expo-router";
|
||
import { useAuthWithProfile } from "~/lib/hooks/useAuthWithProfile";
|
||
import { ROUTES } from "~/lib/routes";
|
||
import ScreenWrapper from "~/components/ui/ScreenWrapper";
|
||
import ModalToast from "~/components/ui/toast";
|
||
import { useTranslation } from "react-i18next";
|
||
|
||
const DonorCard = React.memo(
|
||
({
|
||
name,
|
||
phoneNumber,
|
||
selected,
|
||
onPress,
|
||
}: {
|
||
name: string;
|
||
phoneNumber: string;
|
||
selected: boolean;
|
||
onPress: () => void;
|
||
}) => {
|
||
const borderClass = selected
|
||
? "border-2 border-primary"
|
||
: "border border-gray-200";
|
||
|
||
return (
|
||
<TouchableOpacity
|
||
className={`flex flex-row justify-between w-full items-center py-4 bg-green-50 rounded-md px-3 mb-3 ${borderClass}`}
|
||
onPress={onPress}
|
||
>
|
||
<View className="flex flex-row space-x-3 items-center flex-1">
|
||
<View className="bg-secondary p-2 rounded">
|
||
<LucideUser className="text-white" size={20} />
|
||
</View>
|
||
<View className="w-4" />
|
||
<View className="flex space-y-1 flex-1">
|
||
<Text className="font-dmsans text-primary">{name}</Text>
|
||
<Text className="font-dmsans-medium text-secondary text-sm">
|
||
{phoneNumber}
|
||
</Text>
|
||
</View>
|
||
</View>
|
||
</TouchableOpacity>
|
||
);
|
||
},
|
||
(prevProps, nextProps) => {
|
||
// Custom comparison for better performance
|
||
return (
|
||
prevProps.name === nextProps.name &&
|
||
prevProps.phoneNumber === nextProps.phoneNumber &&
|
||
prevProps.selected === nextProps.selected
|
||
);
|
||
}
|
||
);
|
||
|
||
DonorCard.displayName = "DonorCard";
|
||
|
||
// Selected Donor Pill Component - Memoized for performance
|
||
const SelectedDonorPill = React.memo(
|
||
({
|
||
name,
|
||
phoneNumber,
|
||
onRemove,
|
||
}: {
|
||
name: string;
|
||
phoneNumber: string;
|
||
onRemove: () => void;
|
||
}) => {
|
||
return (
|
||
<View className="flex flex-row items-center bg-primary rounded-full px-3 py-2 mr-2">
|
||
<Text className="text-white font-dmsans-medium text-sm mr-2">
|
||
{name}
|
||
</Text>
|
||
<TouchableOpacity onPress={onRemove} className="ml-1">
|
||
<Text className="text-white font-dmsans-bold text-sm">×</Text>
|
||
</TouchableOpacity>
|
||
</View>
|
||
);
|
||
},
|
||
(prevProps, nextProps) => {
|
||
return (
|
||
prevProps.name === nextProps.name &&
|
||
prevProps.phoneNumber === nextProps.phoneNumber
|
||
);
|
||
}
|
||
);
|
||
|
||
SelectedDonorPill.displayName = "SelectedDonorPill";
|
||
|
||
export default function SelectDonor() {
|
||
const { t } = useTranslation();
|
||
const params = useLocalSearchParams<{
|
||
amount: string;
|
||
selectedContactId?: string;
|
||
selectedContactName?: string;
|
||
selectedContactPhone?: string;
|
||
}>();
|
||
const { user, profile } = useAuthWithProfile();
|
||
const { contacts, loading, error, hasPermission, requestPermission } =
|
||
useContactsStore();
|
||
const {
|
||
recipients,
|
||
loading: recipientsLoading,
|
||
error: recipientsError,
|
||
} = useRecipientsStore();
|
||
const [selectedRecipient, setSelectedRecipient] = useState<string | null>(
|
||
null
|
||
);
|
||
const [note, setNote] = useState("");
|
||
const [search, setSearch] = useState("");
|
||
const [isRequesting, setIsRequesting] = useState(false);
|
||
const [hasInitialized, setHasInitialized] = useState(false);
|
||
const [toastVisible, setToastVisible] = useState(false);
|
||
const [toastTitle, setToastTitle] = useState("");
|
||
const [toastDescription, setToastDescription] = useState<
|
||
string | undefined
|
||
>();
|
||
const toastTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||
|
||
const showToast = useCallback((title: string, description?: string) => {
|
||
if (toastTimeoutRef.current) {
|
||
clearTimeout(toastTimeoutRef.current);
|
||
}
|
||
|
||
setToastTitle(title);
|
||
setToastDescription(description);
|
||
setToastVisible(true);
|
||
|
||
toastTimeoutRef.current = setTimeout(() => {
|
||
setToastVisible(false);
|
||
toastTimeoutRef.current = null;
|
||
}, 2500);
|
||
}, []);
|
||
|
||
// Combine contacts and recipients into a single list - Memoized for performance
|
||
const allRecipients = useMemo(() => {
|
||
const allRecipientsList: Array<{
|
||
id: string;
|
||
name: string;
|
||
phoneNumber: string;
|
||
type: "saved" | "contact";
|
||
}> = [];
|
||
|
||
// Add saved recipients
|
||
recipients.forEach((recipient) => {
|
||
allRecipientsList.push({
|
||
id: recipient.id,
|
||
name: recipient.fullName,
|
||
phoneNumber: recipient.phoneNumber,
|
||
type: "saved",
|
||
});
|
||
});
|
||
|
||
// Add contacts (only if permission granted)
|
||
if (hasPermission && contacts) {
|
||
contacts.forEach((contact) => {
|
||
allRecipientsList.push({
|
||
id: contact.id,
|
||
name: contact.name,
|
||
phoneNumber: contact.phoneNumbers?.[0]?.number || "No phone number",
|
||
type: "contact",
|
||
});
|
||
});
|
||
}
|
||
|
||
// If a contact was selected from the modal, prioritize it
|
||
if (params.selectedContactId) {
|
||
// Find the selected contact and move it to the top
|
||
const selectedContactIndex = allRecipientsList.findIndex(
|
||
(recipient) => recipient.id === params.selectedContactId
|
||
);
|
||
|
||
if (selectedContactIndex > -1) {
|
||
const selectedContact = allRecipientsList.splice(
|
||
selectedContactIndex,
|
||
1
|
||
)[0];
|
||
allRecipientsList.unshift(selectedContact);
|
||
}
|
||
}
|
||
|
||
return allRecipientsList;
|
||
}, [recipients, contacts, hasPermission, params.selectedContactId]);
|
||
|
||
// Handle initial selection from contact modal
|
||
useEffect(() => {
|
||
if (
|
||
params.selectedContactId &&
|
||
!hasInitialized &&
|
||
allRecipients.length > 0
|
||
) {
|
||
// Find the selected contact in the combined list
|
||
const selectedContact = allRecipients.find(
|
||
(recipient) => recipient.id === params.selectedContactId
|
||
);
|
||
|
||
if (selectedContact) {
|
||
setSelectedRecipient(`${selectedContact.type}-${selectedContact.id}`);
|
||
setHasInitialized(true);
|
||
}
|
||
}
|
||
}, [params.selectedContactId, hasInitialized, allRecipients]);
|
||
|
||
// Memoized handler for recipient selection
|
||
const handleRecipientSelect = useCallback((id: string) => {
|
||
setSelectedRecipient(id);
|
||
if (__DEV__) {
|
||
console.log("Selected donor:", id);
|
||
}
|
||
}, []);
|
||
|
||
// Memoized handler for removing selection
|
||
const handleRemoveSelected = useCallback(() => {
|
||
setSelectedRecipient(null);
|
||
setSearch("");
|
||
}, []);
|
||
|
||
// Get selected donor data - Memoized for performance
|
||
const selectedDonorData = useMemo(() => {
|
||
if (!selectedRecipient) return null;
|
||
|
||
return (
|
||
allRecipients.find(
|
||
(recipient) => `${recipient.type}-${recipient.id}` === selectedRecipient
|
||
) || null
|
||
);
|
||
}, [selectedRecipient, allRecipients]);
|
||
|
||
const handleRequestMoney = useCallback(async () => {
|
||
if (!selectedRecipient || !params.amount || !user?.uid || !profile) {
|
||
showToast(
|
||
t("selectdonor.toastErrorTitle"),
|
||
t("selectdonor.toastMissingInfo")
|
||
);
|
||
return;
|
||
}
|
||
|
||
const amountInCents = parseInt(params.amount); // Amount is already in cents
|
||
if (isNaN(amountInCents) || amountInCents <= 0) {
|
||
showToast(
|
||
t("selectdonor.toastErrorTitle"),
|
||
t("selectdonor.toastInvalidAmount")
|
||
);
|
||
return;
|
||
}
|
||
|
||
// Use already computed selectedDonorData
|
||
if (!selectedDonorData) {
|
||
showToast(
|
||
t("selectdonor.toastErrorTitle"),
|
||
t("selectdonor.toastDonorNotFound")
|
||
);
|
||
return;
|
||
}
|
||
|
||
// Navigate directly to payment options (sendbank) with recipient data
|
||
router.push({
|
||
pathname: ROUTES.SEND_BANK,
|
||
params: {
|
||
amount: params.amount,
|
||
recipientName: selectedDonorData.name,
|
||
recipientPhoneNumber: selectedDonorData.phoneNumber,
|
||
recipientType: selectedDonorData.type,
|
||
recipientId: selectedDonorData.id,
|
||
note: note.trim() || "",
|
||
transactionType: "send" as const,
|
||
},
|
||
});
|
||
|
||
// Clear the note and selected recipient after navigation
|
||
setNote("");
|
||
setSelectedRecipient(null);
|
||
}, [
|
||
selectedRecipient,
|
||
params.amount,
|
||
user?.uid,
|
||
profile,
|
||
selectedDonorData,
|
||
note,
|
||
showToast,
|
||
t,
|
||
]);
|
||
|
||
// Filter recipients based on search input - Memoized for performance
|
||
const filteredRecipients = useMemo(() => {
|
||
if (!search.trim()) return allRecipients;
|
||
|
||
const searchTerm = search.toLowerCase().trim();
|
||
return allRecipients.filter((recipient) => {
|
||
const nameMatch = recipient.name.toLowerCase().includes(searchTerm);
|
||
const phoneMatch = recipient.phoneNumber
|
||
.toLowerCase()
|
||
.includes(searchTerm);
|
||
return nameMatch || phoneMatch;
|
||
});
|
||
}, [allRecipients, search]);
|
||
|
||
// Memoized renderItem function for FlatList performance
|
||
const renderDonorItem = useCallback(
|
||
({ item }: { item: (typeof allRecipients)[0] }) => {
|
||
const itemId = `${item.type}-${item.id}`;
|
||
return (
|
||
<DonorCard
|
||
name={item.name}
|
||
phoneNumber={item.phoneNumber}
|
||
selected={selectedRecipient === itemId}
|
||
onPress={() => handleRecipientSelect(itemId)}
|
||
/>
|
||
);
|
||
},
|
||
[selectedRecipient, handleRecipientSelect]
|
||
);
|
||
|
||
// Memoized keyExtractor
|
||
const donorKeyExtractor = useCallback(
|
||
(item: (typeof allRecipients)[0]) => `${item.type}-${item.id}`,
|
||
[]
|
||
);
|
||
|
||
// Memoized ItemSeparator
|
||
const ItemSeparator = useCallback(() => <View className="h-2" />, []);
|
||
|
||
return (
|
||
<ScreenWrapper edges={["top"]}>
|
||
<View className="w-full ">
|
||
<View className="flex flex-row items-stretch justify-between w-full">
|
||
<View>
|
||
<BackButton className="bottom-4" />
|
||
</View>
|
||
<View>
|
||
<Button
|
||
className="bg-primary w-32 p-5 mr-4 rounded-full"
|
||
onPress={handleRequestMoney}
|
||
disabled={!selectedRecipient || isRequesting}
|
||
>
|
||
<Text className="text-white font-dmsans-medium">
|
||
{isRequesting
|
||
? t("selectdonor.requestButtonLoading")
|
||
: t("selectdonor.requestButton")}
|
||
</Text>
|
||
</Button>
|
||
</View>
|
||
</View>
|
||
</View>
|
||
|
||
<View className="w-full px-5">
|
||
<View className="h-8" />
|
||
|
||
{/* Horizontal Divider */}
|
||
<View className="border-t border-gray-200 py-2 -mx-5" />
|
||
|
||
<View className="flex flex-row justify-between items-center space-x-2">
|
||
<Text>{t("selectdonor.toLabel")}</Text>
|
||
<View className="w-2" />
|
||
<View className="flex-1 flex-row items-center">
|
||
{selectedDonorData ? (
|
||
<View className="flex-1 pb-2 flex-row items-center ">
|
||
<SelectedDonorPill
|
||
name={selectedDonorData.name}
|
||
phoneNumber={selectedDonorData.phoneNumber}
|
||
onRemove={handleRemoveSelected}
|
||
/>
|
||
</View>
|
||
) : (
|
||
<Input
|
||
value={search}
|
||
onChangeText={setSearch}
|
||
placeholderText={t("selectdonor.searchPlaceholder")}
|
||
containerClassName="w-full"
|
||
borderClassName="border-0 bg-transparent"
|
||
placeholderColor="#7E7E7E"
|
||
textClassName="text-[#000] text-sm"
|
||
/>
|
||
)}
|
||
</View>
|
||
</View>
|
||
|
||
<View className="border-t border-gray-200 py-2 -mx-5" />
|
||
|
||
<View className="flex flex-row justify-between items-center space-x-2">
|
||
<Text>{t("selectdonor.forLabel")}</Text>
|
||
<Input
|
||
value={note}
|
||
onChangeText={setNote}
|
||
placeholderText={t("selectdonor.notePlaceholder")}
|
||
containerClassName="w-full"
|
||
borderClassName="border-0 bg-transparent"
|
||
placeholderColor="#7E7E7E"
|
||
textClassName="text-[#000] text-sm"
|
||
/>
|
||
</View>
|
||
<View className="border-t border-gray-200 py-2 -mx-5" />
|
||
</View>
|
||
|
||
<ScrollView className="w-full">
|
||
<View className="flex flex-col items-left px-5 w-full">
|
||
{/* Combined Recipients List */}
|
||
<View className="flex flex-col w-full">
|
||
<Text className="text-lg font-dmsans-medium text-gray-800 mb-4">
|
||
{t("selectdonor.donorsTitle", {
|
||
count: filteredRecipients.length,
|
||
})}
|
||
</Text>
|
||
|
||
{/* Loading State */}
|
||
{loading || recipientsLoading ? (
|
||
<View className="flex items-center justify-center py-10">
|
||
<Text className="text-gray-500 font-dmsans">
|
||
{t("selectdonor.loadingDonors")}
|
||
</Text>
|
||
</View>
|
||
) : error || recipientsError ? (
|
||
/* Error State */
|
||
<View className="flex items-center justify-center py-10">
|
||
<Text className="text-red-500 font-dmsans">
|
||
{t("selectdonor.errorTitle")}
|
||
</Text>
|
||
<Text className="text-gray-400 font-dmsans-medium text-sm mt-1 text-center px-4">
|
||
{t("selectdonor.errorWithMessage", {
|
||
error: error || recipientsError,
|
||
})}
|
||
</Text>
|
||
</View>
|
||
) : !hasPermission ? (
|
||
/* Permission Required */
|
||
<View className="flex items-center justify-center py-10">
|
||
<LucideUser className="text-gray-300" size={48} />
|
||
<Text className="text-gray-500 font-dmsans mt-4">
|
||
{t("selectdonor.contactsPermissionTitle")}
|
||
</Text>
|
||
<Text className="text-gray-400 font-dmsans-medium text-sm mt-1 text-center px-4">
|
||
{t("selectdonor.contactsPermissionSubtitle")}
|
||
</Text>
|
||
<Button
|
||
className="bg-primary rounded-md mt-4 px-6"
|
||
onPress={requestPermission}
|
||
>
|
||
<Text className="text-white font-dmsans-medium">
|
||
{t("selectdonor.contactsAllowAccess")}
|
||
</Text>
|
||
</Button>
|
||
</View>
|
||
) : filteredRecipients.length === 0 ? (
|
||
/* Empty State */
|
||
<View className="flex items-center justify-center py-10">
|
||
<LucideUser className="text-gray-300" size={48} />
|
||
<Text className="text-gray-500 font-dmsans mt-4">
|
||
{search.trim()
|
||
? t("selectdonor.emptyTitleSearch")
|
||
: t("selectdonor.emptyTitleDefault")}
|
||
</Text>
|
||
<Text className="text-gray-400 font-dmsans-medium text-sm mt-1 text-center px-4">
|
||
{search.trim()
|
||
? t("selectdonor.emptySubtitleSearch")
|
||
: t("selectdonor.emptySubtitleDefault")}
|
||
</Text>
|
||
</View>
|
||
) : (
|
||
/* Recipients List */
|
||
<FlatList
|
||
data={filteredRecipients}
|
||
keyExtractor={donorKeyExtractor}
|
||
scrollEnabled={false}
|
||
removeClippedSubviews={true}
|
||
maxToRenderPerBatch={10}
|
||
updateCellsBatchingPeriod={50}
|
||
windowSize={10}
|
||
ItemSeparatorComponent={ItemSeparator}
|
||
renderItem={renderDonorItem}
|
||
/>
|
||
)}
|
||
</View>
|
||
</View>
|
||
</ScrollView>
|
||
<ModalToast
|
||
visible={toastVisible}
|
||
title={toastTitle}
|
||
description={toastDescription}
|
||
variant="error"
|
||
/>
|
||
</ScreenWrapper>
|
||
);
|
||
}
|