897 lines
29 KiB
TypeScript
897 lines
29 KiB
TypeScript
import React, { useState, useEffect, useRef } from "react";
|
|
import {
|
|
ScrollView,
|
|
View,
|
|
Keyboard,
|
|
TouchableOpacity,
|
|
Image,
|
|
Alert,
|
|
ActivityIndicator,
|
|
} from "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 } from "expo-router";
|
|
import { useAuthWithProfile } from "~/lib/hooks/useAuthWithProfile";
|
|
import { ROUTES } from "~/lib/routes";
|
|
import { profileUpdateSchema, validate } from "~/lib/utils/validationSchemas";
|
|
import BackButton from "~/components/ui/backButton";
|
|
import ScreenWrapper from "~/components/ui/ScreenWrapper";
|
|
import ModalToast from "~/components/ui/toast";
|
|
import { useTranslation } from "react-i18next";
|
|
import Dropdown, { DropdownOption } from "~/components/ui/dropdown";
|
|
import { useLangStore } from "~/lib/stores";
|
|
import { Copy, Plus, ChevronDown } from "lucide-react-native";
|
|
import * as Clipboard from "expo-clipboard";
|
|
import * as ImagePicker from "expo-image-picker";
|
|
import { Icons } from "~/assets/icons";
|
|
import { AuthService } from "~/lib/services/authServices";
|
|
import { uploadProfileImage } from "~/lib/services/profileImageService";
|
|
import BottomSheet from "~/components/ui/bottomSheet";
|
|
|
|
type ProfileLinkedAccount = {
|
|
id: string;
|
|
bankId: string;
|
|
bankName: string;
|
|
accountNumber: string;
|
|
isDefault: boolean;
|
|
};
|
|
|
|
const PROFILE_BANK_OPTIONS: { id: string; name: string }[] = [
|
|
{ id: "cbe", name: "Commercial Bank of Ethiopia" },
|
|
{ id: "dashen", name: "Dashen Bank" },
|
|
{ id: "abay", name: "Abay Bank" },
|
|
{ id: "awash", name: "Awash Bank" },
|
|
{ id: "hibret", name: "Hibret Bank" },
|
|
{ id: "telebirr", name: "Ethio Telecom (Telebirr)" },
|
|
{ id: "safaricom", name: "Safaricom M-PESA" },
|
|
];
|
|
|
|
export default function EditProfile() {
|
|
const { t } = useTranslation();
|
|
const language = useLangStore((state) => state.language);
|
|
const setLanguage = useLangStore((state) => state.setLanguage);
|
|
const { user, profile, wallet, profileLoading, profileError } =
|
|
useAuthWithProfile();
|
|
const [updateLoading, setUpdateLoading] = useState(false);
|
|
const [updateError, setUpdateError] = useState<string | null>(null);
|
|
|
|
const [profileImage, setProfileImage] = useState<string | null>(null);
|
|
const [imagePicking, setImagePicking] = useState(false);
|
|
|
|
// Editable state for form fields
|
|
const [editedProfile, setEditedProfile] = useState({
|
|
fullName: "",
|
|
phoneNumber: "",
|
|
email: "",
|
|
address: "",
|
|
});
|
|
|
|
const [profileAccounts, setProfileAccounts] = useState<
|
|
ProfileLinkedAccount[]
|
|
>([]);
|
|
const [isSelectAccountSheetVisible, setIsSelectAccountSheetVisible] =
|
|
useState(false);
|
|
const [isAddingAccount, setIsAddingAccount] = useState(false);
|
|
const [selectedBank, setSelectedBank] = useState<string | null>(null);
|
|
const [accountNumberInput, setAccountNumberInput] = useState("");
|
|
const [savingAccount, setSavingAccount] = useState(false);
|
|
const [pendingDefaultAccountId, setPendingDefaultAccountId] = useState<
|
|
string | null
|
|
>(null);
|
|
|
|
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);
|
|
}
|
|
};
|
|
}, []);
|
|
|
|
// Update local state when profile loads
|
|
useEffect(() => {
|
|
if (profile) {
|
|
setEditedProfile({
|
|
fullName: profile.fullName || "",
|
|
phoneNumber: profile.phoneNumber || "",
|
|
email: profile.email || "",
|
|
address: profile.address || "",
|
|
});
|
|
// Only sync image from profile if user hasn't picked a local file
|
|
setProfileImage((prev) => {
|
|
if (prev && prev.startsWith("file:")) {
|
|
return prev;
|
|
}
|
|
return profile.photoUrl || null;
|
|
});
|
|
|
|
const linkedFromProfile: any = (profile as any).linkedAccounts;
|
|
if (Array.isArray(linkedFromProfile)) {
|
|
const mapped: ProfileLinkedAccount[] = linkedFromProfile.map(
|
|
(acc: any) => ({
|
|
id: String(acc.id),
|
|
bankId: String(acc.bankId || ""),
|
|
bankName: String(acc.bankName || ""),
|
|
accountNumber: String(acc.accountNumber || ""),
|
|
isDefault: !!acc.isDefault,
|
|
})
|
|
);
|
|
setProfileAccounts(mapped);
|
|
} else {
|
|
setProfileAccounts([]);
|
|
}
|
|
}
|
|
}, [profile]);
|
|
|
|
const handleSelectProfileImage = async () => {
|
|
try {
|
|
setImagePicking(true);
|
|
const permissionResult =
|
|
await ImagePicker.requestMediaLibraryPermissionsAsync();
|
|
if (!permissionResult.granted) {
|
|
Alert.alert(
|
|
"Permission Required",
|
|
"Please allow access to your photo library to select a profile picture."
|
|
);
|
|
return;
|
|
}
|
|
|
|
const result = await ImagePicker.launchImageLibraryAsync({
|
|
mediaTypes: ImagePicker.MediaTypeOptions.Images,
|
|
allowsEditing: true,
|
|
aspect: [1, 1],
|
|
quality: 0.8,
|
|
});
|
|
|
|
if (!result.canceled && result.assets[0]) {
|
|
const localUri = result.assets[0].uri;
|
|
console.log("[EditProfile] Image selected locally", { localUri });
|
|
// Only update local preview here. Actual upload happens on Save.
|
|
setProfileImage(localUri);
|
|
}
|
|
} catch (e) {
|
|
console.error("[EditProfile] Error while selecting profile image", e);
|
|
showToast("Error", "Failed to select image", "error");
|
|
} finally {
|
|
setImagePicking(false);
|
|
}
|
|
};
|
|
|
|
const handleUpdateProfile = async () => {
|
|
Keyboard.dismiss();
|
|
|
|
if (!user?.uid) {
|
|
showToast(
|
|
t("profile.toastErrorTitle"),
|
|
t("profile.toastUserNotFound"),
|
|
"error"
|
|
);
|
|
return;
|
|
}
|
|
|
|
// Prepare data for validation based on signup type
|
|
const validationData: any = {
|
|
fullName: editedProfile.fullName,
|
|
address: editedProfile.address || undefined,
|
|
};
|
|
|
|
// Only validate phone number if user didn't sign up with phone
|
|
if (profile?.signupType !== "phone") {
|
|
validationData.phoneNumber = editedProfile.phoneNumber || undefined;
|
|
}
|
|
|
|
// Only validate email if user signed up with phone
|
|
if (profile?.signupType === "phone") {
|
|
validationData.email = editedProfile.email || undefined;
|
|
}
|
|
|
|
// Validate form using valibot
|
|
const validationResult = validate(profileUpdateSchema, validationData);
|
|
if (!validationResult.success) {
|
|
showToast("Error", validationResult.error, "warning");
|
|
return;
|
|
}
|
|
|
|
try {
|
|
setUpdateLoading(true);
|
|
setUpdateError(null);
|
|
|
|
// If the user picked a new local image (file://), upload it first
|
|
let finalPhotoUrl: string | undefined = profile?.photoUrl || undefined;
|
|
if (
|
|
profileImage &&
|
|
profileImage !== profile?.photoUrl &&
|
|
profileImage.startsWith("file:")
|
|
) {
|
|
try {
|
|
console.log("[EditProfile] Uploading profile image on save", {
|
|
uid: user.uid,
|
|
localUri: profileImage,
|
|
});
|
|
const uploadedUrl = await uploadProfileImage(user.uid, profileImage);
|
|
console.log(
|
|
"[EditProfile] Profile image uploaded on save, url:",
|
|
uploadedUrl
|
|
);
|
|
finalPhotoUrl = uploadedUrl;
|
|
setProfileImage(uploadedUrl);
|
|
} catch (e) {
|
|
console.error(
|
|
"[EditProfile] Failed to upload profile image on save",
|
|
e
|
|
);
|
|
throw new Error(
|
|
e instanceof Error ? e.message : "Failed to upload profile image"
|
|
);
|
|
}
|
|
}
|
|
|
|
// Prepare update data based on signup type
|
|
const updateData: any = {
|
|
fullName: validationResult.data.fullName,
|
|
address: validationResult.data.address || undefined,
|
|
};
|
|
|
|
if (finalPhotoUrl) {
|
|
updateData.photoUrl = finalPhotoUrl;
|
|
}
|
|
|
|
// Only allow phone number update if user didn't sign up with phone
|
|
if (
|
|
profile?.signupType !== "phone" &&
|
|
validationResult.data.phoneNumber
|
|
) {
|
|
updateData.phoneNumber = validationResult.data.phoneNumber;
|
|
}
|
|
|
|
// Only allow email update if user signed up with phone
|
|
if (profile?.signupType === "phone" && validationResult.data.email) {
|
|
updateData.email = validationResult.data.email;
|
|
}
|
|
|
|
const result = await AuthService.updateUserProfile(user.uid, updateData);
|
|
if (!result.success) {
|
|
throw new Error(result.error || "Failed to update profile");
|
|
}
|
|
|
|
showToast(
|
|
t("profile.toastProfileUpdatedTitle"),
|
|
t("profile.toastProfileUpdatedDescription"),
|
|
"success"
|
|
);
|
|
|
|
// Navigate back to profile after successful update
|
|
setTimeout(() => {
|
|
router.back();
|
|
}, 1000);
|
|
} catch (error) {
|
|
setUpdateError(
|
|
error instanceof Error ? error.message : "Failed to update profile"
|
|
);
|
|
showToast(
|
|
t("profile.toastErrorTitle"),
|
|
t("profile.toastUpdateErrorDescription"),
|
|
"error"
|
|
);
|
|
} finally {
|
|
setUpdateLoading(false);
|
|
}
|
|
};
|
|
|
|
const handleCancel = () => {
|
|
router.back();
|
|
};
|
|
|
|
const handleCopyUsername = async () => {
|
|
if (
|
|
!usernameDisplay ||
|
|
usernameDisplay === t("profile.usernamePlaceholder")
|
|
) {
|
|
showToast("Error", "No username to copy", "error");
|
|
return;
|
|
}
|
|
|
|
await Clipboard.setStringAsync(usernameDisplay);
|
|
showToast("Copied", "Username copied to clipboard", "success");
|
|
};
|
|
|
|
const handleStartAddAccount = () => {
|
|
if (profileAccounts.length >= 5) {
|
|
showToast("Limit reached", "You can link up to 5 accounts.", "warning");
|
|
return;
|
|
}
|
|
setIsSelectAccountSheetVisible(false);
|
|
setSelectedBank(null);
|
|
setAccountNumberInput("");
|
|
setIsAddingAccount(true);
|
|
};
|
|
|
|
const handleSelectBank = (bankId: string) => {
|
|
setSelectedBank(bankId);
|
|
};
|
|
|
|
const handleSaveAccount = async () => {
|
|
if (!selectedBank || !accountNumberInput.trim()) {
|
|
return;
|
|
}
|
|
|
|
if (profileAccounts.length >= 5) {
|
|
showToast("Limit reached", "You can link up to 5 accounts.", "warning");
|
|
return;
|
|
}
|
|
|
|
const bank = PROFILE_BANK_OPTIONS.find((b) => b.id === selectedBank);
|
|
if (!bank) return;
|
|
|
|
const newAccount: ProfileLinkedAccount = {
|
|
id: `${selectedBank}-${Date.now()}`,
|
|
bankId: selectedBank,
|
|
bankName: bank.name,
|
|
accountNumber: accountNumberInput.trim(),
|
|
isDefault: profileAccounts.length === 0,
|
|
};
|
|
|
|
const updatedAccounts = [...profileAccounts, newAccount];
|
|
setProfileAccounts(updatedAccounts);
|
|
|
|
if (!user?.uid) {
|
|
showToast(
|
|
t("profile.toastErrorTitle"),
|
|
t("profile.toastUserNotFound"),
|
|
"error"
|
|
);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
setSavingAccount(true);
|
|
const result = await AuthService.updateUserProfile(user.uid, {
|
|
linkedAccounts: updatedAccounts,
|
|
});
|
|
if (!result.success) {
|
|
throw new Error(result.error || "Failed to save account");
|
|
}
|
|
} catch (error) {
|
|
showToast(
|
|
t("profile.toastErrorTitle"),
|
|
error instanceof Error ? error.message : "Failed to save account",
|
|
"error"
|
|
);
|
|
} finally {
|
|
setSavingAccount(false);
|
|
}
|
|
|
|
setAccountNumberInput("");
|
|
setSelectedBank(null);
|
|
setIsAddingAccount(false);
|
|
};
|
|
|
|
const handleConfirmDefaultAccount = async () => {
|
|
if (!user?.uid) {
|
|
showToast(
|
|
t("profile.toastErrorTitle"),
|
|
t("profile.toastUserNotFound"),
|
|
"error"
|
|
);
|
|
return;
|
|
}
|
|
|
|
if (!pendingDefaultAccountId) {
|
|
setIsSelectAccountSheetVisible(false);
|
|
return;
|
|
}
|
|
|
|
const updatedAccounts = profileAccounts.map((account) => ({
|
|
...account,
|
|
isDefault: account.id === pendingDefaultAccountId,
|
|
}));
|
|
|
|
setProfileAccounts(updatedAccounts);
|
|
|
|
try {
|
|
const result = await AuthService.updateUserProfile(user.uid, {
|
|
linkedAccounts: updatedAccounts,
|
|
});
|
|
if (!result.success) {
|
|
throw new Error(result.error || "Failed to update default account");
|
|
}
|
|
} catch (error) {
|
|
showToast(
|
|
t("profile.toastErrorTitle"),
|
|
error instanceof Error
|
|
? error.message
|
|
: "Failed to update default account",
|
|
"error"
|
|
);
|
|
} finally {
|
|
setIsSelectAccountSheetVisible(false);
|
|
setPendingDefaultAccountId(null);
|
|
}
|
|
};
|
|
|
|
// Show update error
|
|
useEffect(() => {
|
|
if (updateError) {
|
|
showToast(t("profile.toastUpdateErrorTitle"), updateError, "error");
|
|
}
|
|
}, [updateError]);
|
|
|
|
const languageOptions: DropdownOption[] = [
|
|
{ value: "en", label: t("profile.languageOptionEnglish") },
|
|
{ value: "am", label: t("profile.languageOptionAmharic") },
|
|
{ value: "fr", label: t("profile.languageOptionFrench") },
|
|
{ value: "ti", label: t("profile.languageOptionTigrinya") },
|
|
{ value: "om", label: t("profile.languageOptionOromo") },
|
|
];
|
|
|
|
const isTelecomWallet =
|
|
selectedBank === "telebirr" || selectedBank === "safaricom";
|
|
const accountLabel = isTelecomWallet ? "Phone Number" : "Account Number";
|
|
const accountPlaceholder = isTelecomWallet
|
|
? "Enter phone number"
|
|
: "Enter account number";
|
|
|
|
const defaultAccount =
|
|
profileAccounts.find((account) => account.isDefault) || null;
|
|
|
|
const usernameDisplay = profile?.fullName
|
|
? `@${profile.fullName.split(" ")[0]}`
|
|
: user?.email
|
|
? `@${user.email.split("@")[0]}`
|
|
: t("profile.usernamePlaceholder");
|
|
|
|
return (
|
|
<ScreenWrapper edges={[]}>
|
|
<BackButton />
|
|
|
|
<ScrollView
|
|
keyboardShouldPersistTaps="handled"
|
|
keyboardDismissMode="on-drag"
|
|
showsVerticalScrollIndicator={false}
|
|
>
|
|
<View className="flex h-[100%] justify-between px-5 space-y-3 w-full ">
|
|
<View className="py-8">
|
|
<Text className="text-xl font-bold font-dmsans">Edit Profile</Text>
|
|
</View>
|
|
|
|
<View className="flex flex-col space-y-3 pt-5 ">
|
|
<View className="items-center mb-4">
|
|
<TouchableOpacity
|
|
onPress={handleSelectProfileImage}
|
|
activeOpacity={0.8}
|
|
className="relative"
|
|
>
|
|
<View className="w-24 h-24 rounded-full bg-[#C8E6C9] items-center justify-center overflow-hidden relative">
|
|
{profileImage ? (
|
|
<Image
|
|
source={{ uri: profileImage }}
|
|
className="w-24 h-24 rounded-full"
|
|
resizeMode="cover"
|
|
/>
|
|
) : (
|
|
<Image
|
|
source={Icons.avatar}
|
|
style={{ width: 84, height: 84, resizeMode: "contain" }}
|
|
/>
|
|
)}
|
|
{(updateLoading || imagePicking) && (
|
|
<View className="absolute inset-0 items-center justify-center bg-black/20">
|
|
<ActivityIndicator color="#ffffff" />
|
|
</View>
|
|
)}
|
|
</View>
|
|
<View className="absolute bottom-0 -right-2 w-8 h-8 rounded-full bg-primary items-center justify-center border-2 border-white">
|
|
<Plus size={16} color="#fff" strokeWidth={3} />
|
|
</View>
|
|
</TouchableOpacity>
|
|
</View>
|
|
|
|
<View className="flex flex-col space-y-1 py-2 items-left">
|
|
<Text className="text-base font-dmsans text-gray-400">
|
|
{profileLoading ? t("profile.loadingProfile") : ""}
|
|
</Text>
|
|
{profileError && (
|
|
<Text className="text-sm font-dmsans text-red-500">
|
|
{t("profile.errorWithMessage", { error: profileError })}
|
|
</Text>
|
|
)}
|
|
</View>
|
|
|
|
<Label className="text-base font-dmsans-medium">
|
|
{t("profile.fullNameLabel")}
|
|
</Label>
|
|
<View className="h-2" />
|
|
<Input
|
|
placeholder={t("profile.fullNamePlaceholder")}
|
|
value={editedProfile.fullName}
|
|
onChangeText={(text) =>
|
|
setEditedProfile((prev) => ({ ...prev, fullName: text }))
|
|
}
|
|
containerClassName="w-full"
|
|
borderClassName="border-[#D9DBE9] bg-white"
|
|
placeholderColor="#7E7E7E"
|
|
textClassName="text-[#000] text-sm"
|
|
/>
|
|
|
|
<View className="h-2" />
|
|
|
|
<Label className="text-base font-dmsans-medium">
|
|
{t("profile.addressLabel")}
|
|
</Label>
|
|
<View className="h-2" />
|
|
<Input
|
|
placeholder={t("profile.addressPlaceholder")}
|
|
value={editedProfile.address}
|
|
onChangeText={(text) =>
|
|
setEditedProfile((prev) => ({ ...prev, address: text }))
|
|
}
|
|
containerClassName="w-full"
|
|
borderClassName="border-[#D9DBE9] bg-white"
|
|
placeholderColor="#7E7E7E"
|
|
textClassName="text-[#000] text-sm"
|
|
/>
|
|
|
|
<View className="h-2" />
|
|
|
|
<Label className="text-base font-dmsans-medium">
|
|
{t("profile.phoneLabel")}
|
|
</Label>
|
|
<View className="h-2" />
|
|
<Input
|
|
placeholder={t("profile.phonePlaceholder")}
|
|
value={editedProfile.phoneNumber}
|
|
onChangeText={(text) =>
|
|
setEditedProfile((prev) => ({ ...prev, phoneNumber: text }))
|
|
}
|
|
editable={profile?.signupType !== "phone"}
|
|
containerClassName="w-full"
|
|
borderClassName="border-[#D9DBE9] bg-white"
|
|
placeholderColor="#7E7E7E"
|
|
textClassName="text-[#000] text-sm"
|
|
keyboardType="phone-pad"
|
|
/>
|
|
|
|
<View className="h-2" />
|
|
|
|
<Label className="text-base font-dmsans-medium">
|
|
{t("profile.emailLabel")}
|
|
</Label>
|
|
<View className="h-2" />
|
|
<Input
|
|
placeholder={t("profile.emailPlaceholder")}
|
|
value={editedProfile.email}
|
|
onChangeText={(text) =>
|
|
setEditedProfile((prev) => ({ ...prev, email: text }))
|
|
}
|
|
editable={profile?.signupType === "phone"}
|
|
containerClassName="w-full"
|
|
borderClassName="border-[#D9DBE9] bg-white"
|
|
placeholderColor="#7E7E7E"
|
|
textClassName="text-[#000] text-sm"
|
|
/>
|
|
|
|
<View className="h-4" />
|
|
|
|
{/* Language Dropdown */}
|
|
<Label className="text-base font-dmsans-medium">
|
|
{t("profile.languageLabel")}
|
|
</Label>
|
|
<View className="h-2" />
|
|
<Dropdown
|
|
value={language}
|
|
options={languageOptions}
|
|
onSelect={(value) => setLanguage(value as any)}
|
|
placeholder={t("profile.languagePlaceholder")}
|
|
/>
|
|
|
|
<View className="h-4" />
|
|
|
|
{/* Account Number (read-only) */}
|
|
<Label className="text-base font-dmsans-medium">
|
|
{t("profile.accountNumberLabel")}
|
|
</Label>
|
|
<View className="h-2" />
|
|
<View className="space-y-1">
|
|
<TouchableOpacity
|
|
activeOpacity={0.8}
|
|
onPress={() => {
|
|
setPendingDefaultAccountId(defaultAccount?.id || null);
|
|
setIsSelectAccountSheetVisible(true);
|
|
}}
|
|
className={`flex-row items-center justify-between ${
|
|
defaultAccount ? "py-1 px-4" : "py-3 px-4"
|
|
} rounded-md border border-[#D9DBE9]`}
|
|
>
|
|
<View className="flex-1 mr-3">
|
|
<Text className="text-base font-dmsans text-gray-800">
|
|
{defaultAccount
|
|
? defaultAccount.bankName
|
|
: "Choose account"}
|
|
</Text>
|
|
{defaultAccount && (
|
|
<Text className="text-sm font-dmsans text-gray-500 ">
|
|
{defaultAccount.accountNumber}
|
|
</Text>
|
|
)}
|
|
</View>
|
|
<ChevronDown size={18} color="#9CA3AF" />
|
|
</TouchableOpacity>
|
|
|
|
{/* {profileAccounts.length > 0 && (
|
|
<Text className="text-sm pt-1 font-dmsans-medium text-gray-500">
|
|
Tap to change default account or add another.
|
|
</Text>
|
|
)} */}
|
|
</View>
|
|
|
|
<View className="h-4" />
|
|
|
|
{/* Username (read-only) with copy icon */}
|
|
<Label className="text-base font-dmsans-medium">
|
|
{t("profile.usernameLabel")}
|
|
</Label>
|
|
<View className="h-2" />
|
|
<View className="relative">
|
|
<Input
|
|
value={usernameDisplay}
|
|
editable={false}
|
|
containerClassName="w-full"
|
|
borderClassName="border-[#D9DBE9] bg-white"
|
|
placeholderColor="#7E7E7E"
|
|
textClassName="text-[#7E7E7E] text-sm pr-12"
|
|
/>
|
|
<TouchableOpacity
|
|
onPress={handleCopyUsername}
|
|
className="absolute right-3 top-1/2 -translate-y-1/2"
|
|
style={{ transform: [{ translateY: -12 }] }}
|
|
activeOpacity={0.6}
|
|
>
|
|
<Copy size={20} color="#9CA3AF" />
|
|
</TouchableOpacity>
|
|
</View>
|
|
</View>
|
|
</View>
|
|
</ScrollView>
|
|
|
|
<View className="flex flex-col pt-5 px-5 pb-6">
|
|
<View className="flex-row mb-4">
|
|
<View className="flex-1 mr-2">
|
|
<Button
|
|
className="bg-primary rounded-3xl"
|
|
onPress={handleUpdateProfile}
|
|
disabled={updateLoading}
|
|
>
|
|
<Text className="font-dmsans">
|
|
{updateLoading
|
|
? t("profile.savingButton")
|
|
: t("profile.saveButton")}
|
|
</Text>
|
|
</Button>
|
|
</View>
|
|
<View className="flex-1 ml-2">
|
|
<Button
|
|
className="bg-gray-500 rounded-3xl"
|
|
onPress={handleCancel}
|
|
disabled={updateLoading}
|
|
>
|
|
<Text className="font-dmsans text-white">
|
|
{t("profile.cancelButton")}
|
|
</Text>
|
|
</Button>
|
|
</View>
|
|
</View>
|
|
</View>
|
|
|
|
<BottomSheet
|
|
visible={isSelectAccountSheetVisible}
|
|
onClose={() => setIsSelectAccountSheetVisible(false)}
|
|
maxHeightRatio={0.9}
|
|
>
|
|
<View className="mb-4">
|
|
<Text className="text-xl font-dmsans-bold text-primary text-center">
|
|
Choose Account
|
|
</Text>
|
|
</View>
|
|
|
|
{profileAccounts.length === 0 ? (
|
|
<View className="flex-1 items-center justify-center mb-6 px-5">
|
|
<Text className="text-base font-dmsans text-gray-600 text-center">
|
|
You have not added any accounts yet.
|
|
</Text>
|
|
</View>
|
|
) : (
|
|
<View className="mb-4 space-y-2">
|
|
{profileAccounts.map((account) => (
|
|
<TouchableOpacity
|
|
key={account.id}
|
|
activeOpacity={0.8}
|
|
onPress={() => {
|
|
setPendingDefaultAccountId(account.id);
|
|
}}
|
|
className={`flex-row items-center justify-between py-1 px-4 rounded-md mb-2 border ${
|
|
(
|
|
pendingDefaultAccountId
|
|
? pendingDefaultAccountId === account.id
|
|
: account.isDefault
|
|
)
|
|
? "border-primary bg-primary/5"
|
|
: "border-[#D9DBE9] bg-white"
|
|
}`}
|
|
>
|
|
<View className="flex-1 mr-3">
|
|
<Text className="text-base font-dmsans text-gray-800">
|
|
{account.bankName}
|
|
</Text>
|
|
<Text className="text-sm font-dmsans text-gray-500">
|
|
{account.accountNumber}
|
|
</Text>
|
|
</View>
|
|
{account.isDefault && (
|
|
<View className="px-3 py-1 rounded-full bg-primary">
|
|
<Text className="text-xs font-dmsans-medium text-white">
|
|
Default
|
|
</Text>
|
|
</View>
|
|
)}
|
|
</TouchableOpacity>
|
|
))}
|
|
</View>
|
|
)}
|
|
|
|
{profileAccounts.length > 0 && (
|
|
<Button
|
|
className="bg-primary rounded-3xl w-full mt-2"
|
|
onPress={handleConfirmDefaultAccount}
|
|
disabled={
|
|
!pendingDefaultAccountId ||
|
|
pendingDefaultAccountId === defaultAccount?.id
|
|
}
|
|
>
|
|
<Text className="font-dmsans text-white">Save Default</Text>
|
|
</Button>
|
|
)}
|
|
|
|
<Button
|
|
className="bg-primary rounded-3xl w-full mt-2"
|
|
onPress={handleStartAddAccount}
|
|
disabled={profileAccounts.length >= 5}
|
|
>
|
|
<Text className="font-dmsans text-white">Add Account</Text>
|
|
</Button>
|
|
|
|
{profileAccounts.length >= 5 && (
|
|
<Text className="mt-2 text-xs font-dmsans text-gray-500 text-center">
|
|
You can link up to 5 accounts.
|
|
</Text>
|
|
)}
|
|
</BottomSheet>
|
|
|
|
<BottomSheet
|
|
visible={isAddingAccount}
|
|
onClose={() => setIsAddingAccount(false)}
|
|
maxHeightRatio={0.9}
|
|
>
|
|
<View className="mb-4">
|
|
<Text className="text-xl font-dmsans-bold text-primary text-center">
|
|
Add Account
|
|
</Text>
|
|
</View>
|
|
|
|
<View className="mb-4">
|
|
<Text className="text-base font-dmsans text-black mb-2">Bank</Text>
|
|
<View className="flex-row flex-wrap justify-between">
|
|
{PROFILE_BANK_OPTIONS.map((bank) => {
|
|
const isSelected = selectedBank === bank.id;
|
|
const initials = bank.name
|
|
.split(" ")
|
|
.map((part) => part[0])
|
|
.join("")
|
|
.toUpperCase()
|
|
.slice(0, 2);
|
|
|
|
return (
|
|
<TouchableOpacity
|
|
key={bank.id}
|
|
activeOpacity={0.8}
|
|
onPress={() => handleSelectBank(bank.id)}
|
|
className={`items-center justify-between px-3 py-4 mb-3 rounded-2xl border ${
|
|
isSelected
|
|
? "border-primary bg-primary/5"
|
|
: "border-gray-200 bg-white"
|
|
}`}
|
|
style={{ width: "30%" }}
|
|
>
|
|
<View className="w-10 h-10 mb-2 rounded-full bg-primary/10 items-center justify-center">
|
|
<Text className="text-primary font-dmsans-bold text-sm">
|
|
{initials}
|
|
</Text>
|
|
</View>
|
|
<Text
|
|
className="text-center text-xs font-dmsans text-gray-800"
|
|
numberOfLines={2}
|
|
>
|
|
{bank.name}
|
|
</Text>
|
|
</TouchableOpacity>
|
|
);
|
|
})}
|
|
</View>
|
|
</View>
|
|
|
|
<View className="mb-4">
|
|
<Text className="text-base font-dmsans text-black mb-2">
|
|
{accountLabel}
|
|
</Text>
|
|
<Input
|
|
placeholder={accountPlaceholder}
|
|
value={accountNumberInput}
|
|
onChangeText={(text) =>
|
|
setAccountNumberInput(text.replace(/[^0-9]/g, ""))
|
|
}
|
|
containerClassName="w-full mb-4"
|
|
borderClassName="border-[#E5E7EB] bg-white rounded-[4px]"
|
|
placeholderColor="#9CA3AF"
|
|
textClassName="text-[#111827] text-sm"
|
|
keyboardType="number-pad"
|
|
/>
|
|
</View>
|
|
|
|
<Button
|
|
className="bg-primary rounded-3xl w-full"
|
|
onPress={handleSaveAccount}
|
|
disabled={
|
|
!selectedBank ||
|
|
!accountNumberInput.trim() ||
|
|
profileAccounts.length >= 5 ||
|
|
savingAccount
|
|
}
|
|
>
|
|
{savingAccount ? (
|
|
<ActivityIndicator color="#ffffff" />
|
|
) : (
|
|
<Text className="font-dmsans text-white">Save Account</Text>
|
|
)}
|
|
</Button>
|
|
</BottomSheet>
|
|
|
|
<ModalToast
|
|
visible={toastVisible}
|
|
title={toastTitle}
|
|
description={toastDescription}
|
|
variant={toastVariant}
|
|
/>
|
|
</ScreenWrapper>
|
|
);
|
|
}
|