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

572 lines
18 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, useRef } from "react";
import {
View,
Text,
ScrollView,
TouchableOpacity,
FlatList,
ActivityIndicator,
} 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 {
calculateTotalAmountForSending,
calculateProcessingFee,
} from "~/lib/utils/feeUtils";
import { ROUTES } from "~/lib/routes";
import ScreenWrapper from "~/components/ui/ScreenWrapper";
import ModalToast from "~/components/ui/toast";
import { useTranslation } from "react-i18next";
import { UserSearchService } from "~/lib/services/userSearchService";
const RecipientCard = ({
name,
phoneNumber,
selected,
onPress,
}: {
name: string;
phoneNumber: string;
selected: boolean;
onPress: () => void;
}) => {
return (
<TouchableOpacity
className={`flex flex-row justify-between w-full items-center py-4 bg-green-50 rounded-md px-3 mb-3 ${
selected ? "border-2 border-primary" : "border border-gray-200"
}`}
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>
);
};
// Selected Recipient Pill Component
const SelectedRecipientPill = ({
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>
);
};
export default function SelectRecip() {
const { t } = useTranslation();
const params = useLocalSearchParams<{
amount: string;
selectedContactId?: string;
selectedContactName?: string;
selectedContactPhone?: string;
}>();
const { user, wallet, refreshWallet } = 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 [isSending, setIsSending] = useState(false);
const [hasInitialized, setHasInitialized] = useState(false);
const [remoteRecipient, setRemoteRecipient] = useState<{
id: string;
name: string;
phoneNumber: string;
type: "saved" | "contact";
} | null>(null);
const remoteSearchTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(
null
);
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 = (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);
};
useEffect(() => {
return () => {
if (toastTimeoutRef.current) {
clearTimeout(toastTimeoutRef.current);
}
};
}, []);
// Remote user search by email/username (users collection)
useEffect(() => {
if (remoteSearchTimeoutRef.current) {
clearTimeout(remoteSearchTimeoutRef.current);
remoteSearchTimeoutRef.current = null;
}
const term = search.trim();
if (!term || !term.includes("@")) {
setRemoteRecipient(null);
return;
}
remoteSearchTimeoutRef.current = setTimeout(async () => {
const profile = await UserSearchService.findUserByEmail(
term.toLowerCase()
);
if (!profile) {
setRemoteRecipient(null);
return;
}
setRemoteRecipient({
id: profile.uid,
name: profile.fullName || profile.email || "User",
phoneNumber: profile.phoneNumber || "No phone number",
// Treat remote user as a saved recipient-type for transaction metadata
type: "saved",
});
}, 500);
return () => {
if (remoteSearchTimeoutRef.current) {
clearTimeout(remoteSearchTimeoutRef.current);
remoteSearchTimeoutRef.current = null;
}
};
}, [search]);
// Combine contacts and recipients into a single list
const getAllRecipients = () => {
const allRecipients: Array<{
id: string;
name: string;
phoneNumber: string;
type: "saved" | "contact";
}> = [];
// Add saved recipients
recipients.forEach((recipient) => {
allRecipients.push({
id: recipient.id,
name: recipient.fullName,
phoneNumber: recipient.phoneNumber,
type: "saved",
});
});
// Add contacts (only if permission granted)
if (hasPermission && contacts) {
contacts.forEach((contact) => {
allRecipients.push({
id: contact.id,
name: contact.name,
phoneNumber: contact.phoneNumbers?.[0]?.number || "No phone number",
type: "contact",
});
});
}
// Include remote user search result (from users collection) if present
if (remoteRecipient) {
const alreadyExists = allRecipients.some(
(recipient) => recipient.id === remoteRecipient.id
);
if (!alreadyExists) {
allRecipients.unshift(remoteRecipient);
}
}
// If a contact was selected from the modal or QR scan, prioritize it
if (params.selectedContactId) {
const existingIndex = allRecipients.findIndex(
(recipient) => recipient.id === params.selectedContactId
);
if (existingIndex > -1) {
const selectedContact = allRecipients.splice(existingIndex, 1)[0];
allRecipients.unshift(selectedContact);
} else if (params.selectedContactName && params.selectedContactPhone) {
// Synthetic recipient from QR scan or external source
allRecipients.unshift({
id: params.selectedContactId,
name: params.selectedContactName,
phoneNumber: params.selectedContactPhone,
type: "contact",
});
}
}
return allRecipients;
};
// Handle initial selection from contact modal
useEffect(() => {
if (params.selectedContactId && !hasInitialized && contacts && recipients) {
// Find the selected contact in the combined list
const allRecipients = getAllRecipients();
const selectedContact = allRecipients.find(
(recipient) => recipient.id === params.selectedContactId
);
if (selectedContact) {
setSelectedRecipient(`${selectedContact.type}-${selectedContact.id}`);
setHasInitialized(true);
}
}
}, [
params.selectedContactId,
hasInitialized,
contacts,
recipients,
remoteRecipient,
]);
const handleRecipientSelect = (id: string) => {
setSelectedRecipient(id);
console.log("Selected recipient:", id);
};
const handleRemoveSelected = () => {
setSelectedRecipient(null);
setSearch("");
};
// Get selected recipient data
const getSelectedRecipientData = () => {
if (!selectedRecipient) return null;
const allRecipients = getAllRecipients();
return allRecipients.find(
(recipient) => `${recipient.type}-${recipient.id}` === selectedRecipient
);
};
const selectedRecipientData = getSelectedRecipientData();
const handleSendMoney = async () => {
if (!selectedRecipient || !params.amount || !user?.uid) {
showToast(
t("selectrecip.toastErrorTitle"),
t("selectrecip.toastMissingInfo")
);
return;
}
setIsSending(true);
// Let the UI update (show spinner) before running validation and navigation
await new Promise((resolve) => setTimeout(resolve, 0));
let didNavigate = false;
try {
const amountInCents = parseInt(params.amount); // Amount is already in cents
if (isNaN(amountInCents) || amountInCents <= 0) {
showToast(
t("selectrecip.toastErrorTitle"),
t("selectrecip.toastInvalidAmount")
);
return;
}
// Check if user has sufficient balance (including processing fee)
const totalRequired = calculateTotalAmountForSending(amountInCents);
if (!wallet) {
showToast(
t("selectrecip.toastErrorTitle"),
t("selectrecip.toastWalletNotFound")
);
return;
}
if (wallet.balance < totalRequired) {
const processingFee = calculateProcessingFee(amountInCents);
const required = (totalRequired / 100).toFixed(2);
const fee = (processingFee / 100).toFixed(2);
const available = (wallet.balance / 100).toFixed(2);
showToast(
t("selectrecip.toastInsufficientBalanceTitle"),
t("selectrecip.toastInsufficientBalanceDescription", {
required,
fee,
available,
})
);
return;
}
// Find the selected recipient details
const allRecipients = getAllRecipients();
const selectedRecipientData = allRecipients.find(
(recipient) => `${recipient.type}-${recipient.id}` === selectedRecipient
);
if (!selectedRecipientData) {
showToast(
t("selectrecip.toastErrorTitle"),
t("selectrecip.toastRecipientNotFound")
);
return;
}
// Navigate to donation screen first, then continue to confirmation from there
router.push({
pathname: ROUTES.DONATION,
params: {
amount: params.amount,
recipientName: selectedRecipientData.name,
recipientPhoneNumber: selectedRecipientData.phoneNumber,
recipientType: selectedRecipientData.type,
recipientId: selectedRecipientData.id,
note: note.trim() || "",
type: "send",
},
});
didNavigate = true;
// Clear the note and selected recipient after navigation
setNote("");
setSelectedRecipient(null);
} finally {
// If we didn't navigate (validation error), re-enable the button
if (!didNavigate) {
setIsSending(false);
}
// If we did navigate, keep isSending=true; this screen will unmount
}
};
const allRecipients = getAllRecipients();
// Filter recipients based on search input
const filteredRecipients = allRecipients.filter((recipient) => {
if (!search.trim()) return true;
const searchTerm = search.toLowerCase().trim();
const nameMatch = recipient.name.toLowerCase().includes(searchTerm);
const phoneMatch = recipient.phoneNumber.toLowerCase().includes(searchTerm);
return nameMatch || phoneMatch;
});
return (
<ScreenWrapper edges={["top"]}>
<View className="w-full">
<View className="flex flex-row items-stretch justify-between w-full ">
<View className="">
<BackButton className="bottom-4" />
</View>
<View>
<Button
className="bg-primary w-32 p-5 mr-4 rounded-full"
onPress={handleSendMoney}
disabled={!selectedRecipient || isSending}
>
<View className="flex-row items-center justify-center">
{isSending && (
<ActivityIndicator
size="small"
color="#ffffff"
style={{ marginRight: 8 }}
/>
)}
<Text className="text-white font-dmsans-medium">
{isSending
? t("selectrecip.sendButtonLoading")
: t("selectrecip.sendButton")}
</Text>
</View>
</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("selectrecip.toLabel")}</Text>
<View className="w-2" />
<View className="flex-1 flex-row items-center">
{selectedRecipientData ? (
<View className="flex-1 pb-2 flex-row items-center ">
<SelectedRecipientPill
name={selectedRecipientData.name}
phoneNumber={selectedRecipientData.phoneNumber}
onRemove={handleRemoveSelected}
/>
</View>
) : (
<Input
value={search}
onChangeText={setSearch}
placeholderText={t("selectrecip.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("selectrecip.forLabel")}</Text>
<Input
value={note}
onChangeText={setNote}
placeholderText={t("selectrecip.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("selectrecip.recipientsTitle", {
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("selectrecip.loadingRecipients")}
</Text>
</View>
) : error || recipientsError ? (
/* Error State */
<View className="flex items-center justify-center py-10">
<Text className="text-red-500 font-dmsans">
{t("selectrecip.errorTitle")}
</Text>
<Text className="text-gray-400 font-dmsans-medium text-sm mt-1 text-center px-4">
{t("selectrecip.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("selectrecip.contactsPermissionTitle")}
</Text>
<Text className="text-gray-400 font-dmsans-medium text-sm mt-1 text-center px-4">
{t("selectrecip.contactsPermissionSubtitle")}
</Text>
<Button
className="bg-primary rounded-md mt-4 px-6"
onPress={requestPermission}
>
<Text className="text-white font-dmsans-medium">
{t("selectrecip.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("selectrecip.emptyTitleSearch")
: t("selectrecip.emptyTitleDefault")}
</Text>
<Text className="text-gray-400 font-dmsans-medium text-sm mt-1 text-center px-4">
{search.trim()
? t("selectrecip.emptySubtitleSearch")
: t("selectrecip.emptySubtitleDefault")}
</Text>
</View>
) : (
/* Recipients List */
<FlatList
data={filteredRecipients}
keyExtractor={(item) => `${item.type}-${item.id}`}
scrollEnabled={false}
ItemSeparatorComponent={() => <View className="h-2" />}
renderItem={({ item }) => (
<RecipientCard
name={item.name}
phoneNumber={item.phoneNumber}
selected={selectedRecipient === `${item.type}-${item.id}`}
onPress={() =>
handleRecipientSelect(`${item.type}-${item.id}`)
}
/>
)}
/>
)}
</View>
</View>
</ScrollView>
<ModalToast
visible={toastVisible}
title={toastTitle}
description={toastDescription}
variant="error"
/>
</ScreenWrapper>
);
}