freshyo/apps/user-ui/app/(auth)/login.tsx
2026-01-31 15:57:02 +05:30

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;