488 lines
No EOL
16 KiB
TypeScript
488 lines
No EOL
16 KiB
TypeScript
import React, { useState, useEffect, useRef } from "react";
|
|
import { View, Alert, TextInput } from "react-native";
|
|
import { useForm, Controller } from "react-hook-form";
|
|
|
|
import { MyButton, MyText, MyTextInput, tw, StorageServiceCasual, colors, MyTouchableOpacity } from "common-ui";
|
|
import { useAuth } from "@/src/contexts/AuthContext";
|
|
import { trpc } from '@/src/trpc-client';
|
|
import GoogleSignInPKCE from "common-ui/src/components/google-sign-in";
|
|
import { LinearGradient } from "expo-linear-gradient";
|
|
import { KeyboardAwareScrollView } from "react-native-keyboard-aware-scroll-view";
|
|
import { SafeAreaView } from "react-native-safe-area-context";
|
|
|
|
interface LoginFormInputs {
|
|
mobile: string;
|
|
otp?: string;
|
|
password?: string;
|
|
}
|
|
|
|
function Login() {
|
|
const { loginWithToken } = useAuth();
|
|
const [isLoading, setIsLoading] = useState(false);
|
|
const [step, setStep] = useState<'mobile' | 'choice' | 'otp' | 'password'>('mobile');
|
|
const [selectedMobile, setSelectedMobile] = useState('');
|
|
const [canResend, setCanResend] = useState(false);
|
|
const [resendCountdown, setResendCountdown] = useState(0);
|
|
const [otpCells, setOtpCells] = useState(['', '', '', '']);
|
|
const intervalRef = useRef<any | null>(null);
|
|
const inputRefs = useRef<(TextInput | null)[]>([null, null, null, null]);
|
|
|
|
const loginMutation = trpc.user.auth.login.useMutation();
|
|
// const loginMutation = useLogin();
|
|
|
|
// Check for stored OTP timestamp on mount
|
|
useEffect(() => {
|
|
const checkStoredOtpTime = async () => {
|
|
const storedTime = await StorageServiceCasual.getItem('otp_sent_time');
|
|
if (storedTime) {
|
|
const timeDiff = Date.now() - parseInt(storedTime);
|
|
const remainingTime = Math.max(0, 120 - Math.floor(timeDiff / 1000));
|
|
|
|
if (remainingTime > 0) {
|
|
setResendCountdown(remainingTime);
|
|
setCanResend(false);
|
|
} else {
|
|
setCanResend(true);
|
|
setResendCountdown(0);
|
|
}
|
|
} else {
|
|
setCanResend(true);
|
|
}
|
|
};
|
|
|
|
checkStoredOtpTime();
|
|
}, []);
|
|
|
|
// Cleanup interval on unmount
|
|
useEffect(() => {
|
|
return () => {
|
|
if (intervalRef.current) {
|
|
clearInterval(intervalRef.current);
|
|
intervalRef.current = null;
|
|
}
|
|
};
|
|
}, []);
|
|
|
|
// Countdown timer effect
|
|
useEffect(() => {
|
|
// Clear existing interval
|
|
if (intervalRef.current) {
|
|
clearInterval(intervalRef.current);
|
|
intervalRef.current = null;
|
|
}
|
|
|
|
if (resendCountdown > 0) {
|
|
// Set new interval and attach to ref
|
|
intervalRef.current = setInterval(() => {
|
|
setResendCountdown((prev) => {
|
|
if (prev <= 1) {
|
|
setCanResend(true);
|
|
return 0;
|
|
}
|
|
return prev - 1;
|
|
});
|
|
}, 1000);
|
|
}
|
|
|
|
return () => {
|
|
// Cleanup on unmount or dependency change
|
|
if (intervalRef.current) {
|
|
clearInterval(intervalRef.current);
|
|
intervalRef.current = null;
|
|
}
|
|
};
|
|
}, [resendCountdown]);
|
|
|
|
|
|
|
|
const sendOtpMutation = trpc.user.auth.sendOtp.useMutation({
|
|
onSuccess: async (data) => {
|
|
if (data.success) {
|
|
// Save the current timestamp for resend cooldown
|
|
await StorageServiceCasual.setItem('otp_sent_time', Date.now().toString());
|
|
setResendCountdown(120); // 2 minutes
|
|
setCanResend(false);
|
|
setStep('otp');
|
|
Alert.alert('Success', data.message);
|
|
} else {
|
|
Alert.alert('Error', data.message);
|
|
}
|
|
},
|
|
onError: (error: any) => {
|
|
Alert.alert('Error', error.message || 'Failed to send OTP');
|
|
},
|
|
});
|
|
|
|
const verifyOtpMutation = trpc.user.auth.verifyOtp.useMutation({
|
|
onSuccess: (data) => {
|
|
if (data.success && data.token && data.user) {
|
|
loginWithToken(data.token, data.user);
|
|
} else {
|
|
Alert.alert('Error', 'Verification failed');
|
|
}
|
|
},
|
|
onError: (error: any) => {
|
|
Alert.alert('Error', error.message || 'Invalid OTP');
|
|
},
|
|
});
|
|
|
|
|
|
|
|
const {
|
|
control,
|
|
handleSubmit,
|
|
formState: { errors },
|
|
setError,
|
|
clearErrors,
|
|
setValue,
|
|
} = useForm<LoginFormInputs>({
|
|
defaultValues: { mobile: "", otp: "", password: "" },
|
|
});
|
|
|
|
const validateMobile = (mobile: string): boolean => {
|
|
// Remove all non-digit characters
|
|
const cleanMobile = mobile.replace(/\D/g, '');
|
|
// Check if it's a valid Indian mobile number (10 digits, starts with 6-9)
|
|
return cleanMobile.length === 10 && /^[6-9]/.test(cleanMobile);
|
|
};
|
|
|
|
const handleOtpChange = (index: number, text: string) => {
|
|
// Handle paste (multiple characters)
|
|
if (text.length > 1) {
|
|
const digits = text.replace(/\D/g, '').slice(0, 4);
|
|
const newCells = digits.split('').concat(['', '', '', '']).slice(0, 4);
|
|
setOtpCells(newCells);
|
|
const combined = newCells.join('');
|
|
setValue('otp', combined);
|
|
// Focus last filled cell
|
|
const lastIndex = Math.min(digits.length - 1, 3);
|
|
inputRefs.current[lastIndex]?.focus();
|
|
return;
|
|
}
|
|
|
|
// Handle single digit input
|
|
const newCells = [...otpCells];
|
|
newCells[index] = text;
|
|
setOtpCells(newCells);
|
|
const combined = newCells.join('');
|
|
setValue('otp', combined);
|
|
|
|
// Auto-focus logic
|
|
if (text && index < 3) {
|
|
// Move to next cell
|
|
inputRefs.current[index + 1]?.focus();
|
|
} else if (!text && index > 0) {
|
|
// Move to previous cell on delete
|
|
inputRefs.current[index - 1]?.focus();
|
|
}
|
|
};
|
|
|
|
const onSubmit = async (data: LoginFormInputs) => {
|
|
clearErrors();
|
|
|
|
if (step === 'mobile') {
|
|
const mobile = data.mobile.trim();
|
|
// Validate mobile number
|
|
if (!mobile) {
|
|
setError("mobile", {
|
|
type: "manual",
|
|
message: "Mobile number is required",
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (!validateMobile(data.mobile)) {
|
|
setError("mobile", {
|
|
type: "manual",
|
|
message: "Please enter a valid 10-digit mobile number",
|
|
});
|
|
return;
|
|
}
|
|
|
|
const cleanMobile = data.mobile.replace(/\D/g, '');
|
|
setSelectedMobile(cleanMobile);
|
|
sendOtpMutation.mutate({ mobile });
|
|
} else if (step === 'otp') {
|
|
// Verify OTP
|
|
if (!data.otp || data.otp.length < 4) {
|
|
setError("otp", {
|
|
type: "manual",
|
|
message: "Please enter a valid OTP",
|
|
});
|
|
return;
|
|
}
|
|
|
|
verifyOtpMutation.mutate({
|
|
mobile: selectedMobile,
|
|
otp: data.otp,
|
|
});
|
|
} else if (step === 'password') {
|
|
// Login with password
|
|
if (!data.password || data.password.length < 6) {
|
|
setError("password", {
|
|
type: "manual",
|
|
message: "Password must be at least 6 characters",
|
|
});
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const response = await loginMutation.mutateAsync({
|
|
identifier: selectedMobile,
|
|
password: data.password,
|
|
});
|
|
loginWithToken(response.data.token, response.data.user);
|
|
} catch (error: any) {
|
|
Alert.alert('Error', error.message || 'Login failed');
|
|
}
|
|
}
|
|
};
|
|
|
|
return (
|
|
<LinearGradient colors={[colors.brand400, colors.brand700]} style={tw`flex-1`}>
|
|
<SafeAreaView style={tw`flex-1`}>
|
|
<KeyboardAwareScrollView
|
|
contentContainerStyle={tw`flex-grow justify-center p-4`}
|
|
enableOnAndroid={true}
|
|
keyboardShouldPersistTaps="handled"
|
|
extraHeight={100}
|
|
automaticallyAdjustKeyboardInsets={true}
|
|
>
|
|
<View style={tw`mb-8`}>
|
|
<MyText
|
|
weight="bold"
|
|
style={tw`text-4xl mb-2 text-center text-white`}
|
|
>
|
|
Welcome
|
|
</MyText>
|
|
<MyText style={tw`text-lg text-center text-blue-100`}>
|
|
Sign in to continue your journey
|
|
</MyText>
|
|
</View>
|
|
|
|
<View style={tw`bg-white rounded-2xl p-8 shadow-xl mb-6 shadow-blue-900/20`}>
|
|
{step === 'mobile' && (
|
|
<>
|
|
<Controller
|
|
control={control}
|
|
name="mobile"
|
|
rules={{
|
|
required: "Mobile number is required",
|
|
}}
|
|
render={({ field: { onChange, onBlur, value } }) => (
|
|
<View style={tw`mb-5`}>
|
|
<MyTextInput
|
|
topLabel="Mobile Number"
|
|
placeholder="Enter your mobile number"
|
|
value={value}
|
|
onChangeText={(text) => {
|
|
// Format mobile number as user types
|
|
const clean = text.replace(/\D/g, '');
|
|
if (clean.length <= 10) {
|
|
onChange(clean);
|
|
}
|
|
}}
|
|
onBlur={onBlur}
|
|
keyboardType="phone-pad"
|
|
maxLength={10}
|
|
style={tw`bg-gray-50`}
|
|
error={!!errors.mobile}
|
|
/>
|
|
</View>
|
|
)}
|
|
/>
|
|
{errors.mobile && (
|
|
<MyText style={tw`text-red-500 text-sm mb-4 text-center`}>
|
|
{errors.mobile.message}
|
|
</MyText>
|
|
)}
|
|
</>
|
|
)}
|
|
|
|
{step === 'choice' && (
|
|
<View style={tw`mb-6`}>
|
|
<MyText style={tw`text-center text-gray-600 mb-6 font-medium`}>
|
|
Choose your login method for {selectedMobile}
|
|
</MyText>
|
|
<View style={tw`flex-row justify-between mb-6`}>
|
|
<MyButton
|
|
textContent="Use Password"
|
|
onPress={() => setStep('password')}
|
|
fillColor="gray1"
|
|
textColor="black1"
|
|
style={tw`flex-1 mr-2 border border-gray-200`}
|
|
/>
|
|
<MyButton
|
|
textContent="Use OTP"
|
|
onPress={() => sendOtpMutation.mutate({ mobile: selectedMobile })}
|
|
fillColor="brand500"
|
|
textColor="white1"
|
|
style={tw`flex-1 ml-2 shadow-sm`}
|
|
disabled={sendOtpMutation.isPending}
|
|
/>
|
|
</View>
|
|
<MyTouchableOpacity
|
|
onPress={() => {
|
|
setStep('mobile');
|
|
setValue('mobile', '');
|
|
clearErrors();
|
|
}}
|
|
style={tw`mt-2`}
|
|
>
|
|
<MyText weight="semibold" style={tw`text-brand600 text-center`}>
|
|
Change Number
|
|
</MyText>
|
|
</MyTouchableOpacity>
|
|
</View>
|
|
)}
|
|
|
|
{step === 'otp' && (
|
|
<>
|
|
<View style={tw`mb-6`}>
|
|
<MyText
|
|
weight="semibold"
|
|
style={tw`text-base mb-3 text-gray-800 text-center`}
|
|
>
|
|
Enter 4-digit OTP
|
|
</MyText>
|
|
<View style={tw`flex-row justify-center gap-2`}>
|
|
{[0, 1, 2, 3].map((i) => (
|
|
<TextInput
|
|
key={i}
|
|
ref={(ref) => { inputRefs.current[i] = ref; }}
|
|
style={tw`w-14 h-14 ${errors.otp ? 'border-red-500' : 'border-gray-200'} border-2 rounded-xl text-center text-2xl font-bold ${otpCells[i] ? 'bg-blue-50 border-brand500 text-brand700' : 'bg-gray-50'}`}
|
|
keyboardType="numeric"
|
|
maxLength={1}
|
|
value={otpCells[i]}
|
|
onChangeText={(text) => handleOtpChange(i, text)}
|
|
selectionColor={colors.brand500}
|
|
/>
|
|
))}
|
|
</View>
|
|
</View>
|
|
{errors.otp && (
|
|
<MyText style={tw`text-red-500 text-sm mb-4 text-center`}>
|
|
{errors.otp.message}
|
|
</MyText>
|
|
)}
|
|
|
|
<MyTouchableOpacity
|
|
onPress={() => {
|
|
setStep('password');
|
|
setValue('otp', '');
|
|
setOtpCells(['', '', '', '']);
|
|
clearErrors();
|
|
}}
|
|
style={tw`mb-6`}
|
|
>
|
|
<MyText weight="semibold" style={tw`text-brand600 text-center`}>
|
|
Or login with Password
|
|
</MyText>
|
|
</MyTouchableOpacity>
|
|
|
|
<View style={tw`flex-row justify-between items-center mt-2 pt-4 border-t border-gray-100`}>
|
|
<MyTouchableOpacity
|
|
onPress={() => {
|
|
setStep('choice');
|
|
setValue('otp', '');
|
|
setOtpCells(['', '', '', '']);
|
|
clearErrors();
|
|
}}
|
|
>
|
|
<MyText weight="medium" style={tw`text-gray-500`}>
|
|
Back
|
|
</MyText>
|
|
</MyTouchableOpacity>
|
|
|
|
<MyTouchableOpacity
|
|
onPress={() => sendOtpMutation.mutate({ mobile: selectedMobile })}
|
|
disabled={!canResend || sendOtpMutation.isPending}
|
|
>
|
|
<MyText
|
|
weight="semibold"
|
|
style={tw`${canResend && !sendOtpMutation.isPending ? 'text-brand600' : 'text-gray-400'}`}
|
|
>
|
|
{sendOtpMutation.isPending
|
|
? 'Sending...'
|
|
: canResend
|
|
? 'Resend OTP'
|
|
: `Resend in ${resendCountdown}s`
|
|
}
|
|
</MyText>
|
|
</MyTouchableOpacity>
|
|
</View>
|
|
</>
|
|
)}
|
|
|
|
{step === 'password' && (
|
|
<>
|
|
<Controller
|
|
control={control}
|
|
name="password"
|
|
rules={{ required: "Password is required" }}
|
|
render={({ field: { onChange, onBlur, value } }) => (
|
|
<View style={tw`mb-4`}>
|
|
<MyTextInput
|
|
topLabel="Password"
|
|
placeholder="Enter your password"
|
|
value={value}
|
|
onChangeText={onChange}
|
|
onBlur={onBlur}
|
|
secureTextEntry
|
|
style={tw`bg-gray-50`}
|
|
error={!!errors.password}
|
|
/>
|
|
</View>
|
|
)}
|
|
/>
|
|
{errors.password && (
|
|
<MyText style={tw`text-red-500 text-sm mb-4 text-center`}>
|
|
{errors.password.message}
|
|
</MyText>
|
|
)}
|
|
|
|
<View style={tw`flex-row justify-center mb-6`}>
|
|
<MyTouchableOpacity
|
|
onPress={() => {
|
|
setStep('choice');
|
|
setValue('password', '');
|
|
clearErrors();
|
|
}}
|
|
>
|
|
<MyText weight="semibold" style={tw`text-gray-500 font-medium`}>
|
|
Back to options
|
|
</MyText>
|
|
</MyTouchableOpacity>
|
|
</View>
|
|
</>
|
|
)}
|
|
|
|
{(step === 'mobile' || step === 'otp' || step === 'password') && (
|
|
<View style={tw`mt-4`}>
|
|
<MyButton
|
|
onPress={handleSubmit(onSubmit)}
|
|
fillColor="brand600"
|
|
textColor="white1"
|
|
fullWidth
|
|
disabled={
|
|
isLoading ||
|
|
sendOtpMutation.isPending ||
|
|
verifyOtpMutation.isPending ||
|
|
loginMutation.isPending
|
|
}
|
|
style={tw`rounded-xl h-12 shadow-lg shadow-blue-500/30`}
|
|
>
|
|
{isLoading || sendOtpMutation.isPending || verifyOtpMutation.isPending || loginMutation.isPending
|
|
? (step === 'otp' ? "Verifying..." : step === 'password' ? "Logging in..." : "Processing...")
|
|
: (step === 'otp' ? "Verify & Login" : step === 'password' ? "Login" : "Continue")}
|
|
</MyButton>
|
|
</View>
|
|
)}
|
|
</View>
|
|
</KeyboardAwareScrollView>
|
|
</SafeAreaView>
|
|
</LinearGradient>
|
|
);
|
|
}
|
|
|
|
export default Login; |