314 lines
8.6 KiB
TypeScript
314 lines
8.6 KiB
TypeScript
import React, { useState, useEffect } from "react";
|
|
import { View, InteractionManager, ActivityIndicator } from "react-native";
|
|
import { SafeAreaView } from "react-native-safe-area-context";
|
|
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 {
|
|
parseDisplayToCents,
|
|
formatDisplayAmount,
|
|
} from "~/lib/utils/monetaryUtils";
|
|
import { Big } from "big.js";
|
|
import { PinConfirmationModal } from "~/components/ui/pinConfirmationModal";
|
|
import { amountSchema, validate } from "~/lib/utils/validationSchemas";
|
|
import BackButton from "~/components/ui/backButton";
|
|
import { showAlert } from "~/lib/utils/alertUtils";
|
|
import ScreenWrapper from "~/components/ui/ScreenWrapper";
|
|
import { useTabStore } from "~/lib/stores";
|
|
import ModalToast from "~/components/ui/toast";
|
|
import { useTranslation } from "react-i18next";
|
|
|
|
export default function AddCash() {
|
|
const [amount, setAmount] = useState("");
|
|
const [showPinModal, setShowPinModal] = useState(false);
|
|
const [isSecurityVerified, setIsSecurityVerified] = useState(false);
|
|
const { setLastVisitedTab } = useTabStore();
|
|
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);
|
|
};
|
|
|
|
// Set the tab state when component mounts (defer non-critical work)
|
|
useEffect(() => {
|
|
const task = InteractionManager.runAfterInteractions(() => {
|
|
if (__DEV__) {
|
|
console.log("ADD CASH PAGE MOUNTED");
|
|
}
|
|
setLastVisitedTab("/");
|
|
});
|
|
return () => task.cancel();
|
|
}, [setLastVisitedTab]);
|
|
|
|
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
|
|
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);
|
|
return amountInCents >= 1000 && amountInCents <= 99999; // $10.00 to $999.99
|
|
};
|
|
|
|
// Handle PIN confirmation success
|
|
const handlePinSuccess = () => {
|
|
setShowPinModal(false);
|
|
setIsSecurityVerified(true);
|
|
};
|
|
|
|
// Handle add cash action (called after PIN is verified)
|
|
const handleAddCash = () => {
|
|
// Validate amount using valibot (minimum $10.00 = 1000 cents)
|
|
const amountValidationResult = validate(
|
|
amountSchema({ min: 1000, max: 99999, minDisplay: "$10.00" }),
|
|
amount
|
|
);
|
|
if (!amountValidationResult.success) {
|
|
showToast(
|
|
t("addcash.validationErrorTitle"),
|
|
t("addcash.validationEnterAmount"),
|
|
"error"
|
|
);
|
|
return;
|
|
}
|
|
|
|
const amountInCents = parseDisplayToCents(amount);
|
|
|
|
if (amountInCents < 1000) {
|
|
// $10.00 minimum
|
|
showToast(
|
|
t("addcash.validationErrorTitle"),
|
|
t("addcash.validationMinAmount"),
|
|
"error"
|
|
);
|
|
return;
|
|
}
|
|
|
|
if (amountInCents > 99999) {
|
|
// $999.99 maximum
|
|
showToast(
|
|
t("addcash.validationErrorTitle"),
|
|
t("addcash.validationMaxAmount"),
|
|
"error"
|
|
);
|
|
return;
|
|
}
|
|
|
|
console.log("Adding cash:", amountInCents, "cents");
|
|
|
|
// Navigate into the add-cash donation + checkout flow
|
|
router.push({
|
|
pathname: ROUTES.DONATION,
|
|
params: {
|
|
amount: amountInCents.toString(),
|
|
type: "add_cash",
|
|
},
|
|
});
|
|
};
|
|
|
|
return (
|
|
<ScreenWrapper edges={[]}>
|
|
{!isSecurityVerified ? (
|
|
<View className="flex-1 justify-center items-center bg-white">
|
|
<ActivityIndicator size="large" color="hsl(147,55%,28%)" />
|
|
<View className="h-4" />
|
|
<Text className="text-gray-600 font-dmsans text-lg">
|
|
{t("addcash.verifyingSecurity")}
|
|
</Text>
|
|
</View>
|
|
) : (
|
|
<>
|
|
<BackButton />
|
|
<View className="flex h-full">
|
|
<View className="flex-1 justify-start items-center px-5 h-1/6 ">
|
|
<View className="h-12" />
|
|
<Text className="text-3xl font-dmsans text-primary">
|
|
{t("addcash.title")}
|
|
</Text>
|
|
</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={handleAddCash}
|
|
disabled={!isValidAmount()}
|
|
>
|
|
<Text className="font-dmsans text-white">
|
|
{isValidAmount()
|
|
? t("addcash.addButtonWithAmount", {
|
|
amount: formatDisplayAmount(amount),
|
|
})
|
|
: t("addcash.addButton")}
|
|
</Text>
|
|
</Button>
|
|
</View>
|
|
<View className="h-12" />
|
|
</View>
|
|
</View>
|
|
</>
|
|
)}
|
|
|
|
<PinConfirmationModal
|
|
visible={showPinModal}
|
|
onClose={() => setShowPinModal(false)}
|
|
onSuccess={handlePinSuccess}
|
|
title={t("addcash.pinModalTitle")}
|
|
/>
|
|
<ModalToast
|
|
visible={toastVisible}
|
|
title={toastTitle}
|
|
description={toastDescription}
|
|
variant={toastVariant}
|
|
/>
|
|
</ScreenWrapper>
|
|
);
|
|
}
|