You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
760 lines
21 KiB
760 lines
21 KiB
import React, { useState, useRef, useEffect, useCallback } from "react"; |
|
import { |
|
View, |
|
Text, |
|
StyleSheet, |
|
TouchableOpacity, |
|
TextInput, |
|
Modal, |
|
ActivityIndicator, |
|
Platform, |
|
FlatList, |
|
} from "react-native"; |
|
import { useTranslation } from "react-i18next"; |
|
import AsyncStorage from "@react-native-async-storage/async-storage"; |
|
import { CountryList } from "../../constants/countries"; |
|
import { settingApi } from "../../services/api/setting"; |
|
import { VerificationCodeInput } from "./VerificationCodeInput"; |
|
|
|
type ForgotPhonePasswordProps = { |
|
visible?: boolean; |
|
onClose?: () => void; |
|
selectedCountry?: CountryList; |
|
phoneNumber?: string; |
|
}; |
|
|
|
export const ForgotPhonePassword = ({ |
|
visible = true, |
|
onClose = () => {}, |
|
selectedCountry, |
|
phoneNumber = "" |
|
}: ForgotPhonePasswordProps) => { |
|
const { t } = useTranslation(); |
|
|
|
// States |
|
const [phoneNum, setPhoneNum] = useState(phoneNumber); |
|
const [phoneNumberError, setPhoneNumberError] = useState(false); |
|
const [loading, setLoading] = useState(false); |
|
const [error, setError] = useState<string | null>(null); |
|
const [countryList, setCountryList] = useState<CountryList[]>([]); |
|
const [currentCountry, setCurrentCountry] = useState<CountryList | undefined>(selectedCountry); |
|
const [showCountryModal, setShowCountryModal] = useState(false); |
|
const [searchQuery, setSearchQuery] = useState(""); |
|
const [filteredCountryList, setFilteredCountryList] = useState<CountryList[]>([]); |
|
const [showVerificationModal, setShowVerificationModal] = useState(false); |
|
|
|
// Refs |
|
const phoneInputRef = useRef<TextInput>(null); |
|
const searchInputRef = useRef<TextInput>(null); |
|
|
|
// Load country data if not provided |
|
useEffect(() => { |
|
if (visible && !currentCountry) { |
|
loadCountryData(); |
|
} |
|
|
|
if (visible && selectedCountry) { |
|
setCurrentCountry(selectedCountry); |
|
} |
|
}, [visible, selectedCountry]); |
|
|
|
// Focus phone input when modal opens |
|
useEffect(() => { |
|
if (visible) { |
|
const timer = setTimeout(() => { |
|
if (phoneInputRef.current) { |
|
phoneInputRef.current.focus(); |
|
} |
|
}, 300); |
|
|
|
return () => clearTimeout(timer); |
|
} |
|
}, [visible]); |
|
|
|
// Load country data |
|
const loadCountryData = async () => { |
|
try { |
|
const res = await settingApi.getSendSmsCountryList(); |
|
setCountryList(res); |
|
setFilteredCountryList(res); |
|
|
|
const savedCountry = await AsyncStorage.getItem("@selected_country"); |
|
if (savedCountry) { |
|
try { |
|
const parsedCountry = JSON.parse(savedCountry); |
|
const item = res.find(item => item.country === parsedCountry.country); |
|
if (item) { |
|
setCurrentCountry(item); |
|
} else if (res.length > 0) { |
|
setCurrentCountry(res[0]); |
|
} |
|
} catch (e) { |
|
console.error("Error parsing stored country", e); |
|
if (res.length > 0) { |
|
setCurrentCountry(res[0]); |
|
} |
|
} |
|
} else if (res.length > 0) { |
|
setCurrentCountry(res[0]); |
|
} |
|
} catch (error) { |
|
console.error("Failed to load country data", error); |
|
} |
|
}; |
|
|
|
// Filter countries based on search query |
|
useEffect(() => { |
|
if (searchQuery.trim() === "") { |
|
setFilteredCountryList(countryList); |
|
} else { |
|
const query = searchQuery.toLowerCase(); |
|
const filtered = countryList.filter( |
|
country => |
|
country.name_en.toLowerCase().includes(query) || |
|
country.country.toString().includes(query) |
|
); |
|
setFilteredCountryList(filtered); |
|
} |
|
}, [searchQuery, countryList]); |
|
|
|
// Focus search input when country modal opens |
|
useEffect(() => { |
|
if (showCountryModal && searchInputRef.current) { |
|
const timer = setTimeout(() => { |
|
searchInputRef.current?.focus(); |
|
}, 300); |
|
|
|
return () => clearTimeout(timer); |
|
} |
|
}, [showCountryModal]); |
|
|
|
// Clear search when modal closes |
|
useEffect(() => { |
|
if (!showCountryModal) { |
|
setSearchQuery(""); |
|
} |
|
}, [showCountryModal]); |
|
|
|
// Set initial phone number value if provided |
|
useEffect(() => { |
|
if (phoneNumber) { |
|
setPhoneNum(phoneNumber); |
|
} |
|
}, [phoneNumber]); |
|
|
|
// Handle country selection |
|
const handleCountrySelect = (country: CountryList) => { |
|
setCurrentCountry(country); |
|
setShowCountryModal(false); |
|
|
|
// Save selected country to AsyncStorage |
|
AsyncStorage.setItem("@selected_country", JSON.stringify(country)); |
|
|
|
// Reset validation errors when country changes |
|
if (phoneNum) { |
|
setPhoneNumberError(!validatePhoneNumber(phoneNum, country)); |
|
} |
|
}; |
|
|
|
// Render country list item - with performance optimization |
|
const renderCountryItem = useCallback( |
|
({ item }: { item: CountryList }) => ( |
|
<TouchableOpacity |
|
style={styles.countryItem} |
|
onPress={() => handleCountrySelect(item)} |
|
activeOpacity={0.7} |
|
> |
|
<View style={styles.countryItemContent}> |
|
<Text style={styles.countryCode}>+{item.country}</Text> |
|
<Text style={[styles.countryName]}>{item.name_en}</Text> |
|
</View> |
|
{/* Add checkmark for selected country */} |
|
{currentCountry && currentCountry.country === item.country && ( |
|
<Text style={styles.checkmark}>✓</Text> |
|
)} |
|
</TouchableOpacity> |
|
), |
|
[currentCountry] |
|
); |
|
|
|
// Validate phone number |
|
const validatePhoneNumber = (phoneNum: string, country = currentCountry) => { |
|
if (!country || !country.valid_digits || country.valid_digits.length === 0) { |
|
return true; // No validation if no valid_digits available |
|
} |
|
|
|
return country.valid_digits.includes(phoneNum.length); |
|
}; |
|
|
|
// Handle phone number change |
|
const handlePhoneNumberChange = (text: string) => { |
|
setPhoneNum(text); |
|
if (text.length > 0) { |
|
setPhoneNumberError(!validatePhoneNumber(text)); |
|
} else { |
|
setPhoneNumberError(false); |
|
} |
|
setError(null); |
|
}; |
|
|
|
// Handle submit |
|
const handleSubmit = async () => { |
|
if (!validatePhoneNumber(phoneNum)) { |
|
setPhoneNumberError(true); |
|
return; |
|
} |
|
|
|
try { |
|
setLoading(true); |
|
// TODO: Replace with actual API call to send reset code |
|
// For example: await userApi.sendPhonePasswordResetCode({ phone: phoneNum, country: currentCountry?.country }); |
|
|
|
// Log reset method |
|
console.log("Password reset method: Phone"); |
|
try { |
|
// Store reset method in AsyncStorage or other storage |
|
await AsyncStorage.setItem("@password_reset_method", "phone"); |
|
} catch (storageError) { |
|
console.error("Failed to store reset method:", storageError); |
|
} |
|
|
|
// Simulate API call success |
|
setTimeout(() => { |
|
setLoading(false); |
|
setShowVerificationModal(true); |
|
}, 1500); |
|
} catch (error) { |
|
setLoading(false); |
|
setError('Failed to send reset code. Please try again.'); |
|
} |
|
}; |
|
|
|
// Handle verification code submission |
|
const handleVerifyCode = async (code: string): Promise<boolean> => { |
|
// TODO: Replace with actual API call to verify code |
|
// For example: return await userApi.verifyPhonePasswordResetCode({ |
|
// phone: phoneNum, |
|
// country: currentCountry?.country, |
|
// code: code |
|
// }); |
|
|
|
// Simulate verification for demo |
|
return new Promise((resolve) => { |
|
setTimeout(() => { |
|
// For demo: code "123456" is valid, others are invalid |
|
resolve(code === "123456"); |
|
}, 1500); |
|
}); |
|
}; |
|
|
|
// Handle resend code |
|
const handleResendCode = async (): Promise<void> => { |
|
// TODO: Replace with actual API call to resend code |
|
// For example: await userApi.sendPhonePasswordResetCode({ |
|
// phone: phoneNum, |
|
// country: currentCountry?.country |
|
// }); |
|
|
|
// Simulate resend for demo |
|
return new Promise((resolve) => { |
|
setTimeout(() => { |
|
resolve(); |
|
}, 1500); |
|
}); |
|
}; |
|
|
|
// Handle reset password |
|
const handleResetPassword = async (password: string): Promise<boolean> => { |
|
// TODO: Replace with actual API call to reset password |
|
// For example: return await userApi.resetPhonePassword({ |
|
// phone: phoneNum, |
|
// country: currentCountry?.country, |
|
// password: password |
|
// }); |
|
|
|
// Simulate API call for demo |
|
return new Promise((resolve) => { |
|
setTimeout(() => { |
|
// On success, close this modal too |
|
if (onClose) onClose(); |
|
resolve(true); // Always succeed for demo |
|
}, 1500); |
|
}); |
|
}; |
|
|
|
return ( |
|
<Modal |
|
visible={visible} |
|
animationType="slide" |
|
transparent={true} |
|
onRequestClose={onClose} |
|
statusBarTranslucent={true} |
|
> |
|
<View style={styles.modalContainer}> |
|
<View style={styles.forgotPasswordContainer}> |
|
<View style={styles.forgotPasswordHeader}> |
|
<TouchableOpacity |
|
style={styles.forgotPasswordCloseButton} |
|
onPress={onClose} |
|
activeOpacity={0.7} |
|
> |
|
<Text style={styles.forgotPasswordCloseButtonText}>✕</Text> |
|
</TouchableOpacity> |
|
<Text style={styles.forgotPasswordTitle}>{t("login.forgotPassword.title")}</Text> |
|
</View> |
|
|
|
<View style={styles.forgotPasswordContent}> |
|
<Text style={styles.forgotPasswordDescription}> |
|
{t("login.forgotPassword.phoneDescription")} |
|
</Text> |
|
|
|
<View style={styles.phoneInputContainer}> |
|
<TouchableOpacity |
|
style={styles.countryCodeButton} |
|
onPress={() => setShowCountryModal(true)} |
|
> |
|
<Text style={styles.countryCodeText}> |
|
+{currentCountry?.country || ''} |
|
</Text> |
|
<Text style={styles.countryCodeArrow}>▼</Text> |
|
</TouchableOpacity> |
|
|
|
<View style={styles.phoneInputDivider} /> |
|
|
|
<TextInput |
|
ref={phoneInputRef} |
|
style={styles.phoneInput} |
|
placeholder={t("phoneNumber")} |
|
value={phoneNum} |
|
onChangeText={handlePhoneNumberChange} |
|
keyboardType="phone-pad" |
|
autoFocus |
|
maxLength={15} |
|
/> |
|
|
|
{phoneNum.length > 0 && ( |
|
<TouchableOpacity |
|
style={styles.phoneClearButton} |
|
onPress={() => { |
|
setPhoneNum(""); |
|
setPhoneNumberError(false); |
|
setError(null); |
|
}} |
|
activeOpacity={0.7} |
|
> |
|
<Text style={styles.phoneClearButtonText}>✕</Text> |
|
</TouchableOpacity> |
|
)} |
|
</View> |
|
|
|
{phoneNumberError && ( |
|
<Text style={styles.phoneNumberErrorText}> |
|
{t("login.forgotPassword.invalidPhone")} |
|
{currentCountry?.valid_digits && |
|
`(${t("login.forgotPassword.requiresDigits")}: ${currentCountry.valid_digits.join(', ')})`} |
|
</Text> |
|
)} |
|
|
|
{error && ( |
|
<Text style={styles.errorText}> |
|
{error} |
|
</Text> |
|
)} |
|
|
|
<TouchableOpacity |
|
style={[ |
|
styles.submitButton, |
|
(!phoneNum.trim() || phoneNumberError) && styles.disabledButton, |
|
]} |
|
onPress={handleSubmit} |
|
disabled={!phoneNum.trim() || phoneNumberError || loading} |
|
activeOpacity={0.7} |
|
> |
|
{loading ? ( |
|
<ActivityIndicator size="small" color="#fff" /> |
|
) : ( |
|
<Text style={styles.submitButtonText}> |
|
{t("login.forgotPassword.submit")} |
|
</Text> |
|
)} |
|
</TouchableOpacity> |
|
</View> |
|
</View> |
|
|
|
{/* Country selection modal */} |
|
<Modal |
|
visible={showCountryModal} |
|
animationType="slide" |
|
transparent={true} |
|
onRequestClose={() => setShowCountryModal(false)} |
|
hardwareAccelerated={true} |
|
statusBarTranslucent={true} |
|
presentationStyle="overFullScreen" |
|
> |
|
<View style={styles.countryModalContainer}> |
|
<TouchableOpacity |
|
style={styles.countryModalOverlay} |
|
activeOpacity={1} |
|
onPress={() => setShowCountryModal(false)} |
|
/> |
|
<View style={styles.countryModalContent}> |
|
<View style={styles.modalHandleContainer}> |
|
<View style={styles.modalHandle} /> |
|
</View> |
|
<View style={styles.countryModalHeader}> |
|
<TouchableOpacity |
|
style={styles.countryModalCloseButton} |
|
onPress={() => setShowCountryModal(false)} |
|
activeOpacity={0.7} |
|
> |
|
<Text style={styles.countryModalCloseButtonText}>✕</Text> |
|
</TouchableOpacity> |
|
<Text style={styles.countryModalTitle}> |
|
{t("selectCountry")} |
|
</Text> |
|
</View> |
|
|
|
{/* Country search input */} |
|
<View style={styles.searchContainer}> |
|
<View style={styles.searchInputContainer}> |
|
<Text style={styles.searchIcon}>🔍</Text> |
|
<TextInput |
|
ref={searchInputRef} |
|
style={styles.searchInput} |
|
placeholder={t("searchCountry")} |
|
value={searchQuery} |
|
onChangeText={setSearchQuery} |
|
clearButtonMode="while-editing" |
|
autoCapitalize="none" |
|
/> |
|
{searchQuery.length > 0 && ( |
|
<TouchableOpacity |
|
style={styles.searchClearButton} |
|
onPress={() => setSearchQuery("")} |
|
activeOpacity={0.7} |
|
> |
|
<Text style={styles.searchClearButtonText}>✕</Text> |
|
</TouchableOpacity> |
|
)} |
|
</View> |
|
</View> |
|
|
|
<FlatList |
|
data={filteredCountryList} |
|
renderItem={renderCountryItem} |
|
keyExtractor={(item) => item.country.toString()} |
|
style={styles.countryList} |
|
showsVerticalScrollIndicator={false} |
|
removeClippedSubviews={true} |
|
initialNumToRender={10} |
|
maxToRenderPerBatch={10} |
|
windowSize={10} |
|
getItemLayout={(data, index) => ({ |
|
length: 69, |
|
offset: 69 * index, |
|
index, |
|
})} |
|
ListEmptyComponent={() => ( |
|
<View style={styles.emptyResultContainer}> |
|
<Text style={styles.emptyResultText}>{t("noCountriesFound")}</Text> |
|
</View> |
|
)} |
|
/> |
|
</View> |
|
</View> |
|
</Modal> |
|
|
|
{/* Verification Code Modal */} |
|
<VerificationCodeInput |
|
visible={showVerificationModal} |
|
onClose={() => setShowVerificationModal(false)} |
|
phoneNumber={phoneNum} |
|
onVerify={handleVerifyCode} |
|
onResend={handleResendCode} |
|
onResetPassword={handleResetPassword} |
|
/> |
|
</View> |
|
</Modal> |
|
); |
|
}; |
|
|
|
const styles = StyleSheet.create({ |
|
modalContainer: { |
|
flex: 1, |
|
backgroundColor: "rgba(0,0,0,0.5)", |
|
justifyContent: "flex-end", |
|
zIndex: 9999, |
|
}, |
|
forgotPasswordContainer: { |
|
backgroundColor: "#fff", |
|
borderTopLeftRadius: 20, |
|
borderTopRightRadius: 20, |
|
height: "80%", |
|
shadowColor: "#000", |
|
shadowOffset: { width: 0, height: -2 }, |
|
shadowOpacity: 0.1, |
|
shadowRadius: 5, |
|
elevation: 5, |
|
}, |
|
forgotPasswordHeader: { |
|
flexDirection: "row", |
|
alignItems: "center", |
|
paddingTop: 20, |
|
paddingHorizontal: 16, |
|
paddingBottom: 15, |
|
borderBottomWidth: 1, |
|
borderBottomColor: "#f0f0f0", |
|
}, |
|
forgotPasswordCloseButton: { |
|
padding: 8, |
|
width: 36, |
|
height: 36, |
|
justifyContent: "center", |
|
alignItems: "center", |
|
}, |
|
forgotPasswordCloseButtonText: { |
|
fontSize: 18, |
|
color: "#000", |
|
}, |
|
forgotPasswordTitle: { |
|
flex: 1, |
|
fontSize: 18, |
|
fontWeight: "600", |
|
color: "#000", |
|
textAlign: "center", |
|
marginRight: 36, |
|
}, |
|
forgotPasswordContent: { |
|
padding: 20, |
|
paddingBottom: Platform.OS === "ios" ? 50 : 30, |
|
}, |
|
forgotPasswordDescription: { |
|
fontSize: 14, |
|
color: "#333", |
|
marginBottom: 20, |
|
lineHeight: 20, |
|
}, |
|
phoneInputContainer: { |
|
flexDirection: "row", |
|
alignItems: "center", |
|
borderWidth: 1, |
|
borderColor: "#E1E1E1", |
|
borderRadius: 25, |
|
height: 50, |
|
marginBottom: 20, |
|
position: "relative", |
|
}, |
|
countryCodeButton: { |
|
flexDirection: "row", |
|
alignItems: "center", |
|
paddingHorizontal: 12, |
|
height: "100%", |
|
minWidth: 80, |
|
justifyContent: "space-between", |
|
}, |
|
countryCodeText: { |
|
fontSize: 15, |
|
color: "#333", |
|
}, |
|
countryCodeArrow: { |
|
fontSize: 10, |
|
color: "#666", |
|
marginLeft: 4, |
|
}, |
|
phoneInputDivider: { |
|
width: 1, |
|
height: "60%", |
|
backgroundColor: "#E1E1E1", |
|
}, |
|
phoneInput: { |
|
flex: 1, |
|
height: "100%", |
|
paddingLeft: 10, |
|
paddingRight: 36, |
|
fontSize: 16, |
|
}, |
|
phoneClearButton: { |
|
position: "absolute", |
|
right: 12, |
|
top: "50%", |
|
transform: [{ translateY: -12 }], |
|
height: 24, |
|
width: 24, |
|
justifyContent: "center", |
|
alignItems: "center", |
|
}, |
|
phoneClearButtonText: { |
|
fontSize: 16, |
|
color: "#999", |
|
fontWeight: "500", |
|
textAlign: "center", |
|
}, |
|
phoneNumberErrorText: { |
|
color: "#FF3B30", |
|
fontSize: 14, |
|
marginTop: -12, |
|
marginBottom: 16, |
|
paddingHorizontal: 5, |
|
}, |
|
errorText: { |
|
color: "#FF3B30", |
|
fontSize: 14, |
|
marginTop: -12, |
|
marginBottom: 16, |
|
paddingHorizontal: 5, |
|
}, |
|
submitButton: { |
|
height: 50, |
|
backgroundColor: "#0039CB", |
|
borderRadius: 25, |
|
justifyContent: "center", |
|
alignItems: "center", |
|
marginTop: 20, |
|
}, |
|
disabledButton: { |
|
backgroundColor: "#CCCCCC", |
|
}, |
|
submitButtonText: { |
|
color: "#fff", |
|
fontSize: 16, |
|
fontWeight: "600", |
|
}, |
|
// Country modal styles |
|
countryModalContainer: { |
|
flex: 1, |
|
backgroundColor: "rgba(0,0,0,0.5)", |
|
justifyContent: "flex-end", |
|
zIndex: 999, |
|
}, |
|
countryModalOverlay: { |
|
flex: 1, |
|
backgroundColor: "transparent", |
|
}, |
|
countryModalContent: { |
|
backgroundColor: "#fff", |
|
borderTopLeftRadius: 20, |
|
borderTopRightRadius: 20, |
|
height: "80%", |
|
maxHeight: "80%", |
|
shadowColor: "#000", |
|
shadowOffset: { width: 0, height: -2 }, |
|
shadowOpacity: 0.1, |
|
shadowRadius: 5, |
|
elevation: 5, |
|
}, |
|
modalHandleContainer: { |
|
width: "100%", |
|
alignItems: "center", |
|
paddingTop: 12, |
|
paddingBottom: 8, |
|
}, |
|
modalHandle: { |
|
width: 40, |
|
height: 4, |
|
backgroundColor: "#E0E0E0", |
|
borderRadius: 2, |
|
}, |
|
countryModalHeader: { |
|
flexDirection: "row", |
|
alignItems: "center", |
|
padding: 16, |
|
borderBottomWidth: 1, |
|
borderBottomColor: "#E5E5E5", |
|
}, |
|
countryModalCloseButton: { |
|
padding: 4, |
|
}, |
|
countryModalCloseButtonText: { |
|
fontSize: 18, |
|
color: "#999", |
|
}, |
|
countryModalTitle: { |
|
flex: 1, |
|
fontSize: 18, |
|
fontWeight: "600", |
|
textAlign: "center", |
|
marginRight: 24, |
|
}, |
|
countryList: { |
|
padding: 8, |
|
}, |
|
countryItem: { |
|
flexDirection: "row", |
|
alignItems: "center", |
|
padding: 16, |
|
borderBottomWidth: 1, |
|
borderBottomColor: "#F0F0F0", |
|
}, |
|
countryItemContent: { |
|
flex: 1, |
|
flexDirection: "row", |
|
alignItems: "center", |
|
}, |
|
countryName: { |
|
fontSize: 16, |
|
color: "#333", |
|
marginLeft: 10, |
|
}, |
|
countryCode: { |
|
fontSize: 15, |
|
color: "#666", |
|
width: 40, |
|
textAlign: "center", |
|
}, |
|
checkmark: { |
|
fontSize: 20, |
|
color: "#0066FF", |
|
fontWeight: "bold", |
|
marginRight: 10, |
|
}, |
|
// Search styles |
|
searchContainer: { |
|
paddingHorizontal: 16, |
|
paddingVertical: 8, |
|
borderBottomWidth: 1, |
|
borderBottomColor: "#F0F0F0", |
|
}, |
|
searchInputContainer: { |
|
flexDirection: "row", |
|
alignItems: "center", |
|
backgroundColor: "#F5F5F5", |
|
borderRadius: 20, |
|
paddingHorizontal: 12, |
|
height: 40, |
|
position: "relative", |
|
}, |
|
searchIcon: { |
|
fontSize: 16, |
|
marginRight: 8, |
|
color: "#999", |
|
}, |
|
searchInput: { |
|
flex: 1, |
|
height: "100%", |
|
fontSize: 15, |
|
color: "#333", |
|
paddingRight: 30, |
|
}, |
|
searchClearButton: { |
|
position: "absolute", |
|
right: 12, |
|
height: 20, |
|
width: 20, |
|
justifyContent: "center", |
|
alignItems: "center", |
|
}, |
|
searchClearButtonText: { |
|
fontSize: 14, |
|
color: "#999", |
|
fontWeight: "500", |
|
}, |
|
emptyResultContainer: { |
|
padding: 20, |
|
alignItems: "center", |
|
}, |
|
emptyResultText: { |
|
fontSize: 16, |
|
color: "#999", |
|
textAlign: "center", |
|
}, |
|
});
|
|
|