341 lines
9.9 KiB
TypeScript
341 lines
9.9 KiB
TypeScript
import React, { useState, useEffect } from "react";
|
|
import { View, ScrollView, InteractionManager } from "react-native";
|
|
import { useFocusEffect } from "expo-router";
|
|
import { Button } from "~/components/ui/button";
|
|
import { Text } from "~/components/ui/text";
|
|
import { PhonePinKeypad } from "~/components/ui/PhonePinKeypad";
|
|
import { router } from "expo-router";
|
|
import { ROUTES } from "~/lib/routes";
|
|
import { useAuthWithProfile } from "~/lib/hooks/useAuthWithProfile";
|
|
import { useUserWallet } from "~/lib/hooks/useUserWallet";
|
|
import {
|
|
parseDisplayToCents,
|
|
formatDisplayAmount,
|
|
} from "~/lib/utils/monetaryUtils";
|
|
import { Big } from "big.js";
|
|
import BackButton from "~/components/ui/backButton";
|
|
import { PinConfirmationModal } from "~/components/ui/pinConfirmationModal";
|
|
import { amountSchema, validate } from "~/lib/utils/validationSchemas";
|
|
import { showAlert } from "~/lib/utils/alertUtils";
|
|
import ScreenWrapper from "~/components/ui/ScreenWrapper";
|
|
import ModalToast from "~/components/ui/toast";
|
|
import { useTranslation } from "react-i18next";
|
|
import FourDotLoader from "~/components/ui/FourDotLoader";
|
|
|
|
export default function CashOut() {
|
|
const { user } = useAuthWithProfile();
|
|
const { wallet } = useUserWallet(user);
|
|
const [amount, setAmount] = useState("");
|
|
const [showPinModal, setShowPinModal] = useState(false);
|
|
const [isSecurityVerified, setIsSecurityVerified] = useState(false);
|
|
const { t } = useTranslation();
|
|
|
|
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 = React.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(() => {
|
|
const task = InteractionManager.runAfterInteractions(() => {
|
|
if (__DEV__) {
|
|
console.log("CASHOUT PAGE MOUNTED");
|
|
}
|
|
});
|
|
return () => task.cancel();
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
return () => {
|
|
if (toastTimeoutRef.current) {
|
|
clearTimeout(toastTimeoutRef.current);
|
|
}
|
|
};
|
|
}, []);
|
|
|
|
// Reset state and show PIN modal when screen comes into focus
|
|
// useFocusEffect only runs when screen is focused, so we can safely show modal here
|
|
useFocusEffect(
|
|
React.useCallback(() => {
|
|
setIsSecurityVerified(false);
|
|
setShowPinModal(true);
|
|
setAmount("");
|
|
|
|
// Cleanup: hide modal when screen loses focus
|
|
return () => {
|
|
setShowPinModal(false);
|
|
setIsSecurityVerified(false);
|
|
};
|
|
}, [])
|
|
);
|
|
|
|
// Handle number input and special actions (copied from addcash.tsx)
|
|
const handleNumberPress = (input: string) => {
|
|
if (input === "clear") {
|
|
handleClear();
|
|
return;
|
|
}
|
|
|
|
if (input === "backspace") {
|
|
handleBackspace();
|
|
return;
|
|
}
|
|
|
|
// Handle decimal point
|
|
if (input === ".") {
|
|
// Prevent multiple decimals
|
|
if (amount.includes(".")) return;
|
|
|
|
// If empty, start with "0."
|
|
if (amount === "") {
|
|
setAmount("0.");
|
|
return;
|
|
}
|
|
|
|
// Add decimal point
|
|
setAmount(amount + ".");
|
|
return;
|
|
}
|
|
|
|
// Handle digit input (0-9)
|
|
if (!/^[0-9]$/.test(input)) return;
|
|
|
|
// Handle leading zeros
|
|
if (amount === "0" && input !== ".") {
|
|
setAmount(input); // Replace leading zero
|
|
return;
|
|
}
|
|
|
|
const newAmount = amount + input;
|
|
|
|
// Check decimal places limit (max 2 decimal places)
|
|
if (newAmount.includes(".")) {
|
|
const [whole, decimal] = newAmount.split(".");
|
|
if (decimal && decimal.length > 2) return;
|
|
}
|
|
|
|
// Check maximum amount (max $999.99)
|
|
try {
|
|
const numValue = new Big(newAmount);
|
|
if (numValue.gt(999.99)) return;
|
|
} catch (error) {
|
|
return;
|
|
}
|
|
|
|
// Check total length to prevent very long inputs
|
|
if (newAmount.length > 6) return; // Max: 999.99
|
|
|
|
setAmount(newAmount);
|
|
};
|
|
|
|
// Handle backspace
|
|
const handleBackspace = () => {
|
|
if (amount.length === 0) return;
|
|
|
|
// Remove last character
|
|
const newAmount = amount.slice(0, -1);
|
|
setAmount(newAmount);
|
|
};
|
|
|
|
// Clear all input
|
|
const handleClear = () => {
|
|
setAmount("");
|
|
};
|
|
|
|
// Validate if amount is valid for submission
|
|
const isValidAmount = () => {
|
|
if (
|
|
!amount ||
|
|
amount === "" ||
|
|
amount === "0" ||
|
|
amount === "0." ||
|
|
amount === "0.00"
|
|
) {
|
|
return false;
|
|
}
|
|
|
|
const amountInCents = parseDisplayToCents(amount);
|
|
if (amountInCents < 1 || amountInCents > 99999) {
|
|
// 1 cent to $999.99
|
|
return false;
|
|
}
|
|
|
|
// Check if amount is within wallet balance (no processing fee for cashout)
|
|
const currentBalance = wallet?.balance || 0; // Balance in cents
|
|
|
|
return currentBalance >= amountInCents;
|
|
};
|
|
|
|
// Get validation error message
|
|
const getValidationError = () => {
|
|
// Validate basic amount using valibot
|
|
const amountValidationResult = validate(
|
|
amountSchema({ min: 1, max: 99999 }),
|
|
amount
|
|
);
|
|
if (!amountValidationResult.success) {
|
|
return amountValidationResult.error;
|
|
}
|
|
|
|
const amountInCents = parseDisplayToCents(amount);
|
|
if (amountInCents < 1) {
|
|
return t("cashout.validationMinAmount");
|
|
}
|
|
|
|
if (amountInCents > 99999) {
|
|
// $999.99
|
|
return t("cashout.validationMaxAmount");
|
|
}
|
|
|
|
// Check if amount exceeds wallet balance (no processing fee for cashout)
|
|
const currentBalance = wallet?.balance || 0; // Balance in cents
|
|
|
|
if (currentBalance < amountInCents) {
|
|
const balanceInDollars = currentBalance / 100;
|
|
const requiredInDollars = amountInCents / 100;
|
|
return t("cashout.validationInsufficientBalance", {
|
|
required: requiredInDollars.toFixed(2),
|
|
available: balanceInDollars.toFixed(2),
|
|
});
|
|
}
|
|
|
|
return null;
|
|
};
|
|
|
|
// Handle PIN confirmation success
|
|
const handlePinSuccess = () => {
|
|
setShowPinModal(false);
|
|
setIsSecurityVerified(true);
|
|
};
|
|
|
|
// Handle cash out action (called after PIN is verified)
|
|
const handleCashOut = async () => {
|
|
const validationError = getValidationError();
|
|
if (validationError) {
|
|
showToast(t("cashout.validationErrorTitle"), validationError, "error");
|
|
return;
|
|
}
|
|
|
|
const amountInCents = parseDisplayToCents(amount);
|
|
console.log("Cashing out:", amountInCents, "cents");
|
|
|
|
router.push({
|
|
pathname: ROUTES.SEND_BANK,
|
|
params: {
|
|
amount: amountInCents.toString(), // Pass cents as string
|
|
recipientName: user?.displayName || "Self",
|
|
recipientPhoneNumber: user?.phoneNumber || "",
|
|
recipientType: "saved",
|
|
recipientId: user?.uid || "",
|
|
note: "Cash out to bank account",
|
|
transactionType: "cashout",
|
|
},
|
|
});
|
|
};
|
|
|
|
return (
|
|
<ScreenWrapper edges={[]}>
|
|
{!isSecurityVerified && !showPinModal ? (
|
|
<View className="flex-1 justify-center items-center bg-white">
|
|
<Text className="text-gray-600 font-dmsans text-lg mb-4">
|
|
{t("cashout.verifyingSecurity")}
|
|
</Text>
|
|
<FourDotLoader />
|
|
</View>
|
|
) : (
|
|
<View className="h-full">
|
|
<BackButton />
|
|
<View className="flex h-full w-full">
|
|
<View className="flex-1 justify-start items-center px-5 h-1/6 ">
|
|
<View className="h-12" />
|
|
{/* Wallet Balance Display */}
|
|
<View className="flex flex-row items-center space-y-1">
|
|
<View className="h-12" />
|
|
<Text className="text-lg font-dmsans-medium text-gray-600">
|
|
{t("cashout.availableBalanceLabel")}
|
|
</Text>
|
|
<View className="w-2" />
|
|
<Text className="text-lg font-dmsans-medium text-gray-800">
|
|
${wallet ? (wallet.balance / 100).toFixed(2) : "0.00"}
|
|
</Text>
|
|
</View>
|
|
</View>
|
|
|
|
<View className="h-2/6">
|
|
<Text className="text-8xl font-dmsans-bold text-center text-black pt-2">
|
|
${formatDisplayAmount(amount)}
|
|
</Text>
|
|
</View>
|
|
|
|
<View className="h-3/6 flex justify-around">
|
|
<View className="px-8">
|
|
<PhonePinKeypad
|
|
onKeyPress={handleNumberPress}
|
|
showDecimal={true}
|
|
/>
|
|
</View>
|
|
|
|
<View className="px-5">
|
|
<Button
|
|
className="bg-primary rounded-3xl mb-5"
|
|
onPress={handleCashOut}
|
|
disabled={!isValidAmount()}
|
|
>
|
|
<Text className="font-dmsans text-white">
|
|
{isValidAmount()
|
|
? t("cashout.buttonWithAmount", {
|
|
amount: formatDisplayAmount(amount),
|
|
})
|
|
: t("cashout.button")}
|
|
</Text>
|
|
</Button>
|
|
</View>
|
|
<View className="h-12" />
|
|
</View>
|
|
</View>
|
|
</View>
|
|
)}
|
|
|
|
{/* PIN Confirmation Modal */}
|
|
<PinConfirmationModal
|
|
visible={showPinModal}
|
|
onClose={() => setShowPinModal(false)}
|
|
onSuccess={handlePinSuccess}
|
|
title={t("cashout.pinModalTitle")}
|
|
/>
|
|
<ModalToast
|
|
visible={toastVisible}
|
|
title={toastTitle}
|
|
description={toastDescription}
|
|
variant={toastVariant}
|
|
/>
|
|
</ScreenWrapper>
|
|
);
|
|
}
|