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

501 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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>
);
}