522 lines
16 KiB
TypeScript
522 lines
16 KiB
TypeScript
import React, { useEffect, useRef, useState } from "react";
|
|
import {
|
|
Text,
|
|
View,
|
|
ScrollView,
|
|
TextInput,
|
|
ActivityIndicator,
|
|
} from "react-native";
|
|
import { Button } from "~/components/ui/button";
|
|
import { useLocalSearchParams, router } from "expo-router";
|
|
import BackButton from "~/components/ui/backButton";
|
|
import { useAuthWithProfile } from "~/lib/hooks/useAuthWithProfile";
|
|
import { TransactionService } from "~/lib/services/transactionService";
|
|
import {
|
|
calculateTotalAmountForSending,
|
|
calculateProcessingFee,
|
|
} from "~/lib/utils/feeUtils";
|
|
import ScreenWrapper from "~/components/ui/ScreenWrapper";
|
|
import ModalToast from "~/components/ui/toast";
|
|
import { useTranslation } from "react-i18next";
|
|
import { ROUTES } from "~/lib/routes";
|
|
import { applyReferral } from "~/lib/services/referralService";
|
|
import { awardPoints } from "~/lib/services/pointsService";
|
|
|
|
export default function TransConfirm() {
|
|
const { t } = useTranslation();
|
|
const params = useLocalSearchParams<{
|
|
transactionId?: string;
|
|
amount?: string;
|
|
type?: string;
|
|
recipientName?: string;
|
|
recipientPhoneNumber?: string;
|
|
recipientType?: string;
|
|
recipientId?: string;
|
|
note?: string;
|
|
donationSkipped?: string;
|
|
donationType?: string;
|
|
donationAmount?: string;
|
|
donateAnonymously?: string;
|
|
donationCampaignId?: string;
|
|
donationCampaignTitle?: string;
|
|
fromSelectRecipientFlow?: string;
|
|
}>();
|
|
|
|
// Parse amount from cents to dollars
|
|
const amountInCents = parseInt(params.amount || "0");
|
|
const amountInDollars = amountInCents / 100;
|
|
|
|
// Donation amount (entered on donation screen)
|
|
const hasDonation =
|
|
params.donationSkipped === "false" && !!params.donationAmount;
|
|
|
|
const donationAmountNumber = params.donationAmount
|
|
? Number(params.donationAmount)
|
|
: NaN;
|
|
const donationAmountDisplay = isNaN(donationAmountNumber)
|
|
? null
|
|
: donationAmountNumber.toFixed(2);
|
|
|
|
const donationAmountDollars =
|
|
!isNaN(donationAmountNumber) && hasDonation ? donationAmountNumber : 0;
|
|
|
|
// Calculate processing fee (1.25% of the amount)
|
|
const processingFeeInCents = Math.round(amountInCents * 0.0125);
|
|
const processingFeeInDollars = processingFeeInCents / 100;
|
|
|
|
// Subtotal = main amount + donation
|
|
const subtotalInDollars = amountInDollars + donationAmountDollars;
|
|
|
|
// Total = subtotal + processing fee
|
|
const totalInDollars = subtotalInDollars + processingFeeInDollars;
|
|
|
|
const donationTypeLabel =
|
|
params.donationType === "monthly" ? "Monthly" : "One-Time";
|
|
|
|
const transactionDate = new Date();
|
|
const formattedDate = transactionDate.toLocaleDateString(undefined, {
|
|
year: "numeric",
|
|
month: "short",
|
|
day: "numeric",
|
|
});
|
|
|
|
const { user, wallet, refreshWallet } = useAuthWithProfile();
|
|
const [isSending, setIsSending] = useState(false);
|
|
const [referralCode, setReferralCode] = useState("");
|
|
|
|
const [toastVisible, setToastVisible] = useState(false);
|
|
const [toastTitle, setToastTitle] = useState("");
|
|
const [toastDescription, setToastDescription] = useState<string | undefined>(
|
|
undefined
|
|
);
|
|
const [toastVariant, setToastVariant] = useState<
|
|
"success" | "error" | "warning" | "info"
|
|
>("info");
|
|
const toastTimeoutRef = 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);
|
|
};
|
|
|
|
useEffect(() => {
|
|
return () => {
|
|
if (toastTimeoutRef.current) {
|
|
clearTimeout(toastTimeoutRef.current);
|
|
}
|
|
};
|
|
}, []);
|
|
|
|
const handleConfirm = async () => {
|
|
const isAddCashFlow = params.type === "add_cash";
|
|
|
|
if (isAddCashFlow) {
|
|
// If a referral code is provided in the add-cash flow, apply it here
|
|
if (referralCode.trim() && user?.uid) {
|
|
try {
|
|
setIsSending(true);
|
|
const referralResult = await applyReferral({
|
|
referralCode: referralCode.trim(),
|
|
uid: user.uid,
|
|
referralReason: "transaction",
|
|
contextId: params.transactionId || "add_cash",
|
|
});
|
|
|
|
console.log(
|
|
"[TransConfirm] Add-cash referral apply result",
|
|
referralResult
|
|
);
|
|
|
|
if (!referralResult.success) {
|
|
showToast(
|
|
t("transconfirm.toastErrorTitle"),
|
|
referralResult.error || "Failed to apply referral code",
|
|
"error"
|
|
);
|
|
}
|
|
} catch (referralError) {
|
|
console.error(
|
|
"[TransConfirm] Error while applying add-cash referral code",
|
|
referralError
|
|
);
|
|
showToast(
|
|
t("transconfirm.toastErrorTitle"),
|
|
"Failed to apply referral code",
|
|
"error"
|
|
);
|
|
} finally {
|
|
setIsSending(false);
|
|
}
|
|
}
|
|
|
|
router.push({
|
|
pathname: ROUTES.CHECKOUT,
|
|
params: {
|
|
...params,
|
|
},
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (!params.amount || !user?.uid) {
|
|
showToast(
|
|
t("transconfirm.toastErrorTitle"),
|
|
t("transconfirm.toastMissingDetails"),
|
|
"error"
|
|
);
|
|
return;
|
|
}
|
|
|
|
const amountInCents = parseInt(params.amount);
|
|
if (isNaN(amountInCents) || amountInCents <= 0) {
|
|
showToast(
|
|
t("transconfirm.toastErrorTitle"),
|
|
t("transconfirm.toastInvalidAmount"),
|
|
"error"
|
|
);
|
|
return;
|
|
}
|
|
|
|
// Check if user has sufficient balance (including processing fee)
|
|
const totalRequired = calculateTotalAmountForSending(amountInCents);
|
|
if (!wallet) {
|
|
showToast(
|
|
t("transconfirm.toastErrorTitle"),
|
|
t("transconfirm.toastWalletNotFound"),
|
|
"error"
|
|
);
|
|
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("transconfirm.toastInsufficientBalanceTitle"),
|
|
t("transconfirm.toastInsufficientBalanceDescription", {
|
|
required,
|
|
fee,
|
|
available,
|
|
}),
|
|
"error"
|
|
);
|
|
return;
|
|
}
|
|
|
|
if (
|
|
!params.recipientName ||
|
|
!params.recipientPhoneNumber ||
|
|
!params.recipientType ||
|
|
!params.recipientId
|
|
) {
|
|
showToast(
|
|
t("transconfirm.toastErrorTitle"),
|
|
t("transconfirm.toastRecipientMissing"),
|
|
"error"
|
|
);
|
|
return;
|
|
}
|
|
|
|
const isFromSelectRecipientFlow = params.fromSelectRecipientFlow === "true";
|
|
|
|
if (isFromSelectRecipientFlow) {
|
|
router.push({
|
|
pathname: "checkout",
|
|
params: {
|
|
...params,
|
|
},
|
|
});
|
|
return;
|
|
}
|
|
|
|
setIsSending(true);
|
|
|
|
try {
|
|
const result = await TransactionService.sendMoney(user.uid, {
|
|
amount: amountInCents,
|
|
recipientName: params.recipientName,
|
|
recipientPhoneNumber: params.recipientPhoneNumber,
|
|
recipientType: params.recipientType as "saved" | "contact",
|
|
recipientId: params.recipientId,
|
|
note: params.note?.trim() || "",
|
|
});
|
|
|
|
if (result.success) {
|
|
try {
|
|
await awardPoints("send_money");
|
|
} catch (error) {
|
|
console.warn(
|
|
"[TransConfirm] Failed to award send money points",
|
|
error
|
|
);
|
|
}
|
|
|
|
await refreshWallet();
|
|
|
|
if (referralCode.trim() && result.transactionId) {
|
|
try {
|
|
const referralResult = await applyReferral({
|
|
referralCode: referralCode.trim(),
|
|
uid: user.uid,
|
|
referralReason: "transaction",
|
|
contextId: result.transactionId,
|
|
});
|
|
|
|
console.log("[TransConfirm] Referral apply result", referralResult);
|
|
|
|
if (!referralResult.success) {
|
|
console.warn(
|
|
"[TransConfirm] Failed to apply referral:",
|
|
referralResult.error
|
|
);
|
|
showToast(
|
|
t("transconfirm.toastErrorTitle"),
|
|
referralResult.error || "Failed to apply referral code",
|
|
"error"
|
|
);
|
|
}
|
|
} catch (referralError) {
|
|
console.error(
|
|
"[TransConfirm] Error while applying referral code",
|
|
referralError
|
|
);
|
|
showToast(
|
|
t("transconfirm.toastErrorTitle"),
|
|
"Failed to apply referral code",
|
|
"error"
|
|
);
|
|
}
|
|
}
|
|
|
|
// Navigate to success screen with message
|
|
router.replace({
|
|
pathname: "/(screens)/taskcomp",
|
|
params: {
|
|
message: `Transaction completed on your end. $${(
|
|
amountInCents / 100
|
|
).toFixed(2)} will be claimed by ${
|
|
params.recipientName
|
|
} within 7 days, or the money will revert to your wallet.`,
|
|
amount: (amountInCents / 100).toFixed(2),
|
|
recipientName: params.recipientName,
|
|
recipientPhoneNumber: params.recipientPhoneNumber,
|
|
},
|
|
});
|
|
} else {
|
|
showToast(
|
|
t("transconfirm.toastErrorTitle"),
|
|
result.error || t("transconfirm.toastSendFailed"),
|
|
"error"
|
|
);
|
|
}
|
|
} catch (error) {
|
|
console.error("Error sending money:", error);
|
|
showToast(
|
|
t("transconfirm.toastErrorTitle"),
|
|
t("transconfirm.toastSendFailedWithRetry"),
|
|
"error"
|
|
);
|
|
} finally {
|
|
setIsSending(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<ScreenWrapper edges={[]}>
|
|
<View className="flex-1 w-full">
|
|
<ScrollView
|
|
className="w-full flex-1"
|
|
contentContainerStyle={{ flexGrow: 1, paddingBottom: 24 }}
|
|
>
|
|
<View className="flex-1 w-full">
|
|
<View className="pt-6">
|
|
<BackButton />
|
|
</View>
|
|
|
|
{/* Centered content: title + card */}
|
|
<View className="flex-1 justify-center">
|
|
<View
|
|
className="flex flex-col pb-5 items-center"
|
|
style={{ gap: 4 }}
|
|
>
|
|
<Text className="text-xl font-dmsans-bold text-black">
|
|
{t("transconfirm.title")}
|
|
</Text>
|
|
</View>
|
|
|
|
<View className="bg-secondary-foreground mx-6 rounded py-10 mt-2">
|
|
<View className="flex items-center py-5" style={{ gap: 8 }}>
|
|
<Text className="text-2xl font-dmsans-bold text-primary">
|
|
${amountInDollars.toFixed(2)}
|
|
</Text>
|
|
<Text
|
|
className="text-sm text-gray-500 font-dmsans"
|
|
style={{ textAlign: "center" }}
|
|
>
|
|
{params.type === "add_cash"
|
|
? "You are planning to add money to your wallet."
|
|
: t("transconfirm.planningDescription", {
|
|
recipientName: params.recipientName,
|
|
})}
|
|
</Text>
|
|
</View>
|
|
<View
|
|
className="flex px-2 py-5 border-t border-b border-gray-300 m-3"
|
|
style={{ gap: 8 }}
|
|
>
|
|
<Text className="text-sm font-dmsans-bold mt-5">
|
|
{t("transconfirm.sectionTitle")}
|
|
</Text>
|
|
|
|
{params.note && (
|
|
<View className="flex flex-row justify-between w-full">
|
|
<Text className="font-dmsans">
|
|
{t("transconfirm.noteLabel")}
|
|
</Text>
|
|
<Text
|
|
className="font-dmsans"
|
|
style={{ textAlign: "right" }}
|
|
>
|
|
{params.note}
|
|
</Text>
|
|
</View>
|
|
)}
|
|
|
|
{hasDonation && donationAmountDisplay && (
|
|
<>
|
|
<View className="flex flex-row justify-between w-full">
|
|
<Text className="font-dmsans">Donation</Text>
|
|
<Text
|
|
className="font-dmsans"
|
|
style={{ textAlign: "right" }}
|
|
>
|
|
${donationAmountDisplay}
|
|
</Text>
|
|
</View>
|
|
|
|
<View className="flex flex-row justify-between w-full">
|
|
<Text className="font-dmsans">Donation Type</Text>
|
|
<Text
|
|
className="font-dmsans"
|
|
style={{ textAlign: "right" }}
|
|
>
|
|
{donationTypeLabel}
|
|
</Text>
|
|
</View>
|
|
</>
|
|
)}
|
|
|
|
<View className="flex flex-row justify-between w-full mt-3">
|
|
<Text className="font-dmsans">Date</Text>
|
|
<Text
|
|
className="font-dmsans"
|
|
style={{ textAlign: "right" }}
|
|
>
|
|
{formattedDate}
|
|
</Text>
|
|
</View>
|
|
|
|
<View className="flex flex-row justify-between w-full">
|
|
<Text className="font-dmsans">
|
|
{t("transconfirm.processingFeeLabel")}
|
|
</Text>
|
|
<Text
|
|
className="font-dmsans"
|
|
style={{ textAlign: "right" }}
|
|
>
|
|
${processingFeeInDollars.toFixed(2)}
|
|
</Text>
|
|
</View>
|
|
|
|
<View className="flex flex-row justify-between w-full">
|
|
<Text className="font-dmsans">
|
|
{t("transconfirm.subtotalLabel")}
|
|
</Text>
|
|
<Text
|
|
className="font-dmsans"
|
|
style={{ textAlign: "right" }}
|
|
>
|
|
${subtotalInDollars.toFixed(2)}
|
|
</Text>
|
|
</View>
|
|
|
|
<View className="flex flex-row justify-between w-full border-t border-gray-300 pt-5">
|
|
<Text className="font-dmsans-bold text-base">
|
|
{t("transconfirm.totalLabel")}
|
|
</Text>
|
|
<Text
|
|
className="font-dmsans text-base"
|
|
style={{ textAlign: "right" }}
|
|
>
|
|
${totalInDollars.toFixed(2)}
|
|
</Text>
|
|
</View>
|
|
</View>
|
|
</View>
|
|
|
|
<View className="px-6 mt-6">
|
|
<Text className="text-base font-dmsans text-black mb-2">
|
|
{t("transconfirm.referralCodeLabel", "Referral Code")}
|
|
</Text>
|
|
<TextInput
|
|
value={referralCode}
|
|
onChangeText={setReferralCode}
|
|
placeholder="678"
|
|
placeholderTextColor="#9CA3AF"
|
|
className="w-full rounded-2xl px-4 py-3 bg-[#FFF5E9] border border-[#D9E2D5] font-dmsans text-base text-black"
|
|
/>
|
|
</View>
|
|
</View>
|
|
</View>
|
|
</ScrollView>
|
|
|
|
<View className="w-full px-5 pb-8 pt-4" style={{ gap: 12 }}>
|
|
<Button
|
|
className="bg-primary rounded-full"
|
|
onPress={handleConfirm}
|
|
disabled={isSending}
|
|
>
|
|
{isSending ? (
|
|
<View
|
|
className="flex-row items-center justify-center"
|
|
style={{ gap: 8 }}
|
|
>
|
|
<ActivityIndicator size="small" color="#FFFFFF" />
|
|
<Text className="font-dmsans text-white">
|
|
{t("transconfirm.buttonProcessing")}
|
|
</Text>
|
|
</View>
|
|
) : (
|
|
<Text className="font-dmsans text-white">
|
|
{t("transconfirm.buttonConfirm")}
|
|
</Text>
|
|
)}
|
|
</Button>
|
|
</View>
|
|
</View>
|
|
<ModalToast
|
|
visible={toastVisible}
|
|
title={toastTitle}
|
|
description={toastDescription}
|
|
variant={toastVariant}
|
|
/>
|
|
</ScreenWrapper>
|
|
);
|
|
}
|