572 lines
18 KiB
TypeScript
572 lines
18 KiB
TypeScript
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>
|
||
);
|
||
}
|