425 lines
12 KiB
TypeScript
425 lines
12 KiB
TypeScript
import React, { useState, useEffect } from "react";
|
|
import { ScrollView, View, TouchableOpacity, Keyboard } from "react-native";
|
|
import { LucideEye, EyeOff, Trash2, CreditCard } from "lucide-react-native";
|
|
import { Button } from "~/components/ui/button";
|
|
import { Input } from "~/components/ui/input";
|
|
import { Label } from "~/components/ui/label";
|
|
import { Text } from "~/components/ui/text";
|
|
import { router, useLocalSearchParams } from "expo-router";
|
|
import { useAuthWithProfile } from "~/lib/hooks/useAuthWithProfile";
|
|
import { useUserWallet } from "~/lib/hooks/useUserWallet";
|
|
import { CreditCard as CreditCardType } from "~/lib/services/walletService";
|
|
import { useTabStore } from "~/lib/stores";
|
|
import { addCardSchema, validate } from "~/lib/utils/validationSchemas";
|
|
|
|
import BackButton from "~/components/ui/backButton";
|
|
import { ROUTES } from "~/lib/routes";
|
|
import ScreenWrapper from "~/components/ui/ScreenWrapper";
|
|
import ModalToast from "~/components/ui/toast";
|
|
import { useTranslation } from "react-i18next";
|
|
|
|
// Credit Card Component
|
|
const CreditCardComponent = ({
|
|
card,
|
|
onRemove,
|
|
}: {
|
|
card: CreditCardType;
|
|
onRemove: () => void;
|
|
}) => {
|
|
const getCardColor = (cardType: string) => {
|
|
switch (cardType?.toLowerCase()) {
|
|
case "visa":
|
|
return "bg-blue-700";
|
|
case "mastercard":
|
|
return "bg-red-600";
|
|
case "american express":
|
|
return "bg-green-700";
|
|
case "discover":
|
|
return "bg-orange-600";
|
|
default:
|
|
return "bg-gray-700";
|
|
}
|
|
};
|
|
|
|
const getCardIcon = (cardType: string) => {
|
|
switch (cardType?.toLowerCase()) {
|
|
case "visa":
|
|
return "VISA";
|
|
case "mastercard":
|
|
return "MC";
|
|
case "american express":
|
|
return "AMEX";
|
|
case "discover":
|
|
return "DISC";
|
|
default:
|
|
return "CARD";
|
|
}
|
|
};
|
|
|
|
return (
|
|
<View
|
|
className={`w-80 h-48 rounded-xl ${getCardColor(
|
|
card.cardType || ""
|
|
)} p-6 relative shadow-lg`}
|
|
style={{ overflow: "visible" }}
|
|
>
|
|
{/* Card Brand and Remove Button */}
|
|
<View className="flex-row justify-between items-start mb-4">
|
|
<View className="bg-white/20 px-3 py-1 rounded-md">
|
|
<Text className="text-white font-dmsans-bold text-sm">
|
|
{getCardIcon(card.cardType || "")}
|
|
</Text>
|
|
</View>
|
|
<TouchableOpacity
|
|
onPress={() => {
|
|
console.log("Remove button pressed for card:", card.id);
|
|
onRemove();
|
|
}}
|
|
className="bg-gray-500/80 p-3 rounded-full min-w-10 min-h-10 flex items-center justify-center"
|
|
activeOpacity={0.7}
|
|
style={{ zIndex: 10 }}
|
|
>
|
|
<Trash2 color="#FFFFFF" size={18} />
|
|
</TouchableOpacity>
|
|
</View>
|
|
|
|
{/* Card Number */}
|
|
<View className="mb-6">
|
|
<Text className="text-white font-dmsans-mono text-lg tracking-wider">
|
|
{card.cardNumber}
|
|
</Text>
|
|
</View>
|
|
|
|
{/* Card Details */}
|
|
<View className="flex-row justify-between items-end">
|
|
<View>
|
|
<Text className="text-white/70 font-dmsans text-xs uppercase tracking-wide mb-1">
|
|
Valid Thru
|
|
</Text>
|
|
<Text className="text-white font-dmsans-medium text-sm">
|
|
{card.expiryDate}
|
|
</Text>
|
|
</View>
|
|
<View>
|
|
<Text className="text-white/70 font-dmsans text-xs uppercase tracking-wide mb-1">
|
|
Card Type
|
|
</Text>
|
|
<Text className="text-white font-dmsans-medium text-sm capitalize">
|
|
{card.cardType || "Unknown"}
|
|
</Text>
|
|
</View>
|
|
</View>
|
|
|
|
{/* Decorative Elements */}
|
|
<View className="absolute top-4 right-4 opacity-20">
|
|
<CreditCard color="#FFFFFF" size={32} />
|
|
</View>
|
|
<View className="absolute bottom-4 right-4 opacity-10">
|
|
<View className="w-12 h-8 bg-white/30 rounded-sm" />
|
|
</View>
|
|
</View>
|
|
);
|
|
};
|
|
|
|
export default function AddCard() {
|
|
const { user } = useAuthWithProfile();
|
|
const { addCreditCard, loading, error } = useUserWallet(user);
|
|
const { setLastVisitedTab } = useTabStore();
|
|
const { from } = useLocalSearchParams<{ from?: string }>();
|
|
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 based on where user came from
|
|
useEffect(() => {
|
|
if (from === "addcash") {
|
|
setLastVisitedTab("/(tabs)/cardmang");
|
|
} else {
|
|
setLastVisitedTab("/"); // Default to home if no specific from parameter
|
|
}
|
|
}, [setLastVisitedTab, from]);
|
|
|
|
useEffect(() => {
|
|
return () => {
|
|
if (toastTimeoutRef.current) {
|
|
clearTimeout(toastTimeoutRef.current);
|
|
}
|
|
};
|
|
}, []);
|
|
|
|
// Form state
|
|
const [cardNumber, setCardNumber] = useState("");
|
|
const [expiryDate, setExpiryDate] = useState("");
|
|
const [cvv, setCvv] = useState("");
|
|
const [showCvv, setShowCvv] = useState(false);
|
|
|
|
// Format card number as user types (add spaces every 4 digits)
|
|
const formatCardNumber = (value: string) => {
|
|
const v = value.replace(/\s+/g, "").replace(/[^0-9]/gi, "");
|
|
const matches = v.match(/\d{4,16}/g);
|
|
const match = (matches && matches[0]) || "";
|
|
const parts = [];
|
|
|
|
for (let i = 0, len = match.length; i < len; i += 4) {
|
|
parts.push(match.substring(i, i + 4));
|
|
}
|
|
|
|
if (parts.length) {
|
|
return parts.join(" ");
|
|
} else {
|
|
return v;
|
|
}
|
|
};
|
|
|
|
// Format expiry date as MM/YY
|
|
const formatExpiryDate = (value: string) => {
|
|
const v = value.replace(/\s+/g, "").replace(/[^0-9]/gi, "");
|
|
if (v.length >= 2) {
|
|
return `${v.substring(0, 2)}/${v.substring(2, 4)}`;
|
|
}
|
|
return v;
|
|
};
|
|
|
|
const handleCardNumberChange = (value: string) => {
|
|
const formatted = formatCardNumber(value);
|
|
if (formatted.length <= 19) {
|
|
// 16 digits + 3 spaces
|
|
setCardNumber(formatted);
|
|
}
|
|
};
|
|
|
|
const handleExpiryDateChange = (value: string) => {
|
|
const formatted = formatExpiryDate(value);
|
|
if (formatted.length <= 5) {
|
|
// MM/YY
|
|
setExpiryDate(formatted);
|
|
}
|
|
};
|
|
|
|
const handleCvvChange = (value: string) => {
|
|
const v = value.replace(/[^0-9]/gi, "");
|
|
if (v.length <= 4) {
|
|
setCvv(v);
|
|
}
|
|
};
|
|
|
|
const handleAddCard = async () => {
|
|
Keyboard.dismiss();
|
|
|
|
// Basic field validation
|
|
if (!cardNumber.trim()) {
|
|
showToast(
|
|
t("addcard.validationErrorTitle"),
|
|
t("addcard.validationCardNumberRequired"),
|
|
"error"
|
|
);
|
|
return;
|
|
}
|
|
|
|
if (!expiryDate.trim()) {
|
|
showToast(
|
|
t("addcard.validationErrorTitle"),
|
|
t("addcard.validationExpiryRequired"),
|
|
"error"
|
|
);
|
|
return;
|
|
}
|
|
|
|
if (!cvv.trim()) {
|
|
showToast(
|
|
t("addcard.validationErrorTitle"),
|
|
t("addcard.validationCvvRequired"),
|
|
"error"
|
|
);
|
|
return;
|
|
}
|
|
|
|
// Validate form using valibot
|
|
const validationResult = validate(addCardSchema, {
|
|
cardNumber,
|
|
expiryDate,
|
|
cvv,
|
|
});
|
|
|
|
if (!validationResult.success) {
|
|
showToast(
|
|
t("addcard.validationErrorTitle"),
|
|
validationResult.error || t("addcard.validationInvalidCard"),
|
|
"error"
|
|
);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
await addCreditCard({
|
|
cardNumber: validationResult.data.cardNumber.replace(/\s/g, ""), // Remove spaces
|
|
expiryDate: validationResult.data.expiryDate,
|
|
cvv: validationResult.data.cvv,
|
|
});
|
|
|
|
router.replace(ROUTES.CARD_ADDED);
|
|
} catch (err) {
|
|
showToast(
|
|
t("addcard.toastErrorTitle"),
|
|
t("addcard.toastAddFailed"),
|
|
"error"
|
|
);
|
|
}
|
|
};
|
|
|
|
// Show errors
|
|
useEffect(() => {
|
|
if (error) {
|
|
showToast(t("addcard.toastErrorTitle"), error, "error");
|
|
}
|
|
}, [error]);
|
|
|
|
return (
|
|
<ScreenWrapper edges={[]}>
|
|
<View className="flex-1 w-full justify-between">
|
|
<ScrollView
|
|
className="flex-1"
|
|
showsVerticalScrollIndicator={false}
|
|
contentContainerStyle={{ paddingBottom: 24 }}
|
|
>
|
|
<BackButton />
|
|
<View className="flex flex-col space-y-1 py-5 pt-8 items-center">
|
|
<Text className="text-2xl font-semibold font-dmsans text-black">
|
|
{t("addcard.title")}
|
|
</Text>
|
|
</View>
|
|
|
|
<View className="h-12" />
|
|
<View className="px-5">
|
|
<View>
|
|
<Text className="text-primary font-dmsans-medium">
|
|
{t("addcard.sectionCardTitle")}
|
|
</Text>
|
|
</View>
|
|
<View className="h-4" />
|
|
<View>
|
|
<Text className="font-dmsans-medium">
|
|
{t("addcard.sectionCardSubtitle")}
|
|
</Text>
|
|
</View>
|
|
<View className="h-12" />
|
|
|
|
<Label className="text-base font-dmsans-medium">
|
|
{t("addcard.cardNumberLabel")}
|
|
</Label>
|
|
<View className="h-2" />
|
|
<Input
|
|
placeholder={t("addcard.cardNumberPlaceholder")}
|
|
value={cardNumber}
|
|
onChangeText={handleCardNumberChange}
|
|
keyboardType="numeric"
|
|
maxLength={19}
|
|
containerClassName="w-full"
|
|
borderClassName="border-[#D9DBE9] bg-white"
|
|
placeholderColor="#7E7E7E"
|
|
textClassName="text-[#000] text-sm"
|
|
/>
|
|
|
|
<View className="h-4" />
|
|
|
|
<Label className="text-base font-dmsans-medium">
|
|
{t("addcard.expiryDateLabel")}
|
|
</Label>
|
|
<View className="h-2" />
|
|
<Input
|
|
placeholder={t("addcard.expiryDatePlaceholder")}
|
|
value={expiryDate}
|
|
onChangeText={handleExpiryDateChange}
|
|
keyboardType="numeric"
|
|
maxLength={5}
|
|
containerClassName="w-full"
|
|
borderClassName="border-[#D9DBE9] bg-white"
|
|
placeholderColor="#7E7E7E"
|
|
textClassName="text-[#000] text-sm"
|
|
/>
|
|
|
|
<View className="h-4" />
|
|
|
|
<Label className="text-base font-dmsans-medium">
|
|
{t("addcard.cvvLabel")}
|
|
</Label>
|
|
<View className="h-2" />
|
|
<Input
|
|
placeholder={t("addcard.cvvPlaceholder")}
|
|
value={cvv}
|
|
onChangeText={handleCvvChange}
|
|
keyboardType="numeric"
|
|
secureTextEntry={!showCvv}
|
|
maxLength={4}
|
|
containerClassName="w-full"
|
|
borderClassName="border-[#D9DBE9] bg-white"
|
|
placeholderColor="#7E7E7E"
|
|
textClassName="text-[#000] text-sm"
|
|
rightIcon={
|
|
<TouchableOpacity onPress={() => setShowCvv(!showCvv)}>
|
|
{showCvv ? (
|
|
<EyeOff color="#4B5563" size={20} />
|
|
) : (
|
|
<LucideEye color="#4B5563" size={20} />
|
|
)}
|
|
</TouchableOpacity>
|
|
}
|
|
/>
|
|
|
|
<View className="h-4" />
|
|
</View>
|
|
</ScrollView>
|
|
<View className="w-full px-5 pb-8">
|
|
<Button
|
|
className="bg-primary rounded-3xl"
|
|
onPress={handleAddCard}
|
|
disabled={
|
|
loading || !cardNumber.trim() || !expiryDate.trim() || !cvv.trim()
|
|
}
|
|
>
|
|
<Text className="font-dmsans text-white">
|
|
{loading ? t("addcard.addButtonLoading") : t("addcard.addButton")}
|
|
</Text>
|
|
</Button>
|
|
</View>
|
|
</View>
|
|
<ModalToast
|
|
visible={toastVisible}
|
|
title={toastTitle}
|
|
description={toastDescription}
|
|
variant={toastVariant}
|
|
/>
|
|
</ScreenWrapper>
|
|
);
|
|
}
|