feat: implement core screens and authentication infrastructure including cart, login, and profile flows
This commit is contained in:
parent
44f6050e60
commit
7a0fd59892
5
ReactotronConfig.js
Normal file
5
ReactotronConfig.js
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
import Reactotron from 'reactotron-react-native';
|
||||||
|
|
||||||
|
Reactotron.configure() // controls connection & communication settings
|
||||||
|
.useReactNative() // add all built-in react native plugins
|
||||||
|
.connect(); // let's connect!
|
||||||
@ -6,6 +6,20 @@ buildscript {
|
|||||||
targetSdkVersion = 36
|
targetSdkVersion = 36
|
||||||
ndkVersion = "27.1.12297006"
|
ndkVersion = "27.1.12297006"
|
||||||
kotlinVersion = "2.1.20"
|
kotlinVersion = "2.1.20"
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------
|
||||||
|
// Pin Google Play Services versions so all libraries use the same
|
||||||
|
// artifact. react-native-geolocation-service defaults to 18.0.0
|
||||||
|
// but react-native-maps requires 21.x where FusedLocationProvider-
|
||||||
|
// Client is an interface, not a class. Using two different versions
|
||||||
|
// causes IncompatibleClassChangeError and a hard app crash.
|
||||||
|
// ---------------------------------------------------------------
|
||||||
|
googlePlayServicesVersion = "21.3.0" // used by geolocation-service
|
||||||
|
playServicesVersion = "21.3.0" // fallback alias
|
||||||
|
playServicesLocationVersion = "21.3.0" // used by geolocation-service
|
||||||
|
googlePlayServicesLocationVersion = "21.3.0" // used by react-native-maps
|
||||||
|
googlePlayServicesMapsVersion = "19.1.0"
|
||||||
|
googlePlayServicesBaseVersion = "18.5.0"
|
||||||
}
|
}
|
||||||
repositories {
|
repositories {
|
||||||
google()
|
google()
|
||||||
@ -19,3 +33,13 @@ buildscript {
|
|||||||
}
|
}
|
||||||
|
|
||||||
apply plugin: "com.facebook.react.rootproject"
|
apply plugin: "com.facebook.react.rootproject"
|
||||||
|
|
||||||
|
// Force a single version of play-services-location across ALL modules
|
||||||
|
// to prevent IncompatibleClassChangeError at runtime.
|
||||||
|
subprojects {
|
||||||
|
configurations.all {
|
||||||
|
resolutionStrategy {
|
||||||
|
force "com.google.android.gms:play-services-location:21.3.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -1,20 +1,8 @@
|
|||||||
import { apiClient } from '../services/apiClient';
|
import { apiClient } from '../services/apiClient';
|
||||||
import { User } from '../interfaces';
|
import { LoginResponse, User, VerifyOtpResponse } from '../interfaces';
|
||||||
|
|
||||||
// ─── Types ───────────────────────────────────────────────────────────────────
|
// ─── Types ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export interface LoginResponse {
|
|
||||||
success: boolean;
|
|
||||||
message: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface VerifyOtpResponse {
|
|
||||||
success: boolean;
|
|
||||||
user: User;
|
|
||||||
accessToken: string;
|
|
||||||
refreshToken: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UpdateProfileResponse {
|
export interface UpdateProfileResponse {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
user: User;
|
user: User;
|
||||||
@ -26,11 +14,11 @@ export interface LogoutResponse {
|
|||||||
|
|
||||||
// ─── Endpoints ───────────────────────────────────────────────────────────────
|
// ─── Endpoints ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export const loginApi = (mobileNumber: string) =>
|
export const loginApi = (phone: string) =>
|
||||||
apiClient.post<LoginResponse>('/auth/login', { mobileNumber });
|
apiClient.post<LoginResponse>('/auth/otp/request', { phone });
|
||||||
|
|
||||||
export const verifyOtpApi = (mobileNumber: string, otp: string) =>
|
export const verifyOtpApi = (phone: string, code: string, role: string) =>
|
||||||
apiClient.post<VerifyOtpResponse>('/auth/verify-otp', { mobileNumber, otp });
|
apiClient.post<VerifyOtpResponse>('/auth/otp/verify', { phone, code, role });
|
||||||
|
|
||||||
export const updateProfileApi = (data: {
|
export const updateProfileApi = (data: {
|
||||||
name: string;
|
name: string;
|
||||||
|
|||||||
@ -9,7 +9,8 @@ import {
|
|||||||
import { getStyles } from './loginScreen.styles';
|
import { getStyles } from './loginScreen.styles';
|
||||||
import { CustomInput, PrimaryButton } from '@components';
|
import { CustomInput, PrimaryButton } from '@components';
|
||||||
import { useAppTheme } from '@theme';
|
import { useAppTheme } from '@theme';
|
||||||
import { useLoginScreen } from '../../../hooks';
|
import { useLoginScreen } from './hooks/useLoginScreen';
|
||||||
|
import { RootState, useAppSelector } from '@store';
|
||||||
|
|
||||||
export const LoginScreen: React.FC = () => {
|
export const LoginScreen: React.FC = () => {
|
||||||
const { colors } = useAppTheme();
|
const { colors } = useAppTheme();
|
||||||
@ -18,6 +19,8 @@ export const LoginScreen: React.FC = () => {
|
|||||||
const { mobileNumber, error, onChangeMobileNumber, handleLogin } =
|
const { mobileNumber, error, onChangeMobileNumber, handleLogin } =
|
||||||
useLoginScreen();
|
useLoginScreen();
|
||||||
|
|
||||||
|
const { isLoading } = useAppSelector((state: RootState) => state.auth);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<KeyboardAvoidingView
|
<KeyboardAvoidingView
|
||||||
style={styles.container}
|
style={styles.container}
|
||||||
@ -69,9 +72,10 @@ export const LoginScreen: React.FC = () => {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<PrimaryButton
|
<PrimaryButton
|
||||||
title="Get OTP"
|
title={isLoading ? 'Loading...' : 'Get OTP'}
|
||||||
onPress={handleLogin}
|
onPress={handleLogin}
|
||||||
style={{ marginTop: 12 }}
|
style={{ marginTop: 12 }}
|
||||||
|
disabled={isLoading}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
|||||||
@ -2,7 +2,7 @@ import { useState, useCallback } from 'react';
|
|||||||
import { useNavigation } from '@react-navigation/native';
|
import { useNavigation } from '@react-navigation/native';
|
||||||
import { StackNavigationProp } from '@react-navigation/stack';
|
import { StackNavigationProp } from '@react-navigation/stack';
|
||||||
import { AuthStackParamList } from 'app/navigation/authStack';
|
import { AuthStackParamList } from 'app/navigation/authStack';
|
||||||
import { loginWithPhone, useAppDispatch } from '../store';
|
import { loginWithPhone, useAppDispatch } from '@store';
|
||||||
|
|
||||||
type LoginNavProp = StackNavigationProp<AuthStackParamList, 'LoginScreen'>;
|
type LoginNavProp = StackNavigationProp<AuthStackParamList, 'LoginScreen'>;
|
||||||
|
|
||||||
@ -9,10 +9,10 @@ import { StackNavigationProp } from '@react-navigation/stack';
|
|||||||
import { getStyles } from './accountScreen.styles';
|
import { getStyles } from './accountScreen.styles';
|
||||||
import { Header, PrimaryButton } from '@components';
|
import { Header, PrimaryButton } from '@components';
|
||||||
import { useAppTheme } from '@theme';
|
import { useAppTheme } from '@theme';
|
||||||
import { useAppDispatch, useAppSelector } from '../../../hooks/useAppDispatch';
|
|
||||||
import { logout } from '../../../store/commonreducers/auth';
|
import { logout } from '../../../store/commonreducers/auth';
|
||||||
import { AppStackParamList } from '../../../navigation/appStack';
|
import { AppStackParamList } from '../../../navigation/appStack';
|
||||||
import { MainTabParamList } from '../../../navigation/mainTabNavigator';
|
import { MainTabParamList } from '../../../navigation/mainTabNavigator';
|
||||||
|
import { useAppDispatch, useAppSelector } from '@store';
|
||||||
|
|
||||||
type AccountNavProp = CompositeNavigationProp<
|
type AccountNavProp = CompositeNavigationProp<
|
||||||
BottomTabNavigationProp<MainTabParamList, 'AccountScreen'>,
|
BottomTabNavigationProp<MainTabParamList, 'AccountScreen'>,
|
||||||
|
|||||||
@ -11,7 +11,6 @@ import { StackNavigationProp } from '@react-navigation/stack';
|
|||||||
import { getStyles } from './cartScreen.styles';
|
import { getStyles } from './cartScreen.styles';
|
||||||
import { Header, QuantitySelector, PrimaryButton } from '@components';
|
import { Header, QuantitySelector, PrimaryButton } from '@components';
|
||||||
import { useAppTheme } from '@theme';
|
import { useAppTheme } from '@theme';
|
||||||
import { useAppDispatch, useAppSelector } from '../../../hooks/useAppDispatch';
|
|
||||||
import {
|
import {
|
||||||
removeItem,
|
removeItem,
|
||||||
applyCoupon,
|
applyCoupon,
|
||||||
@ -19,6 +18,7 @@ import {
|
|||||||
selectCartTotal,
|
selectCartTotal,
|
||||||
} from '../../../store/commonreducers/cart';
|
} from '../../../store/commonreducers/cart';
|
||||||
import { AppStackParamList } from '../../../navigation/appStack';
|
import { AppStackParamList } from '../../../navigation/appStack';
|
||||||
|
import { useAppDispatch, useAppSelector } from '@store';
|
||||||
|
|
||||||
type CartScreenNavProp = StackNavigationProp<AppStackParamList, 'CartScreen'>;
|
type CartScreenNavProp = StackNavigationProp<AppStackParamList, 'CartScreen'>;
|
||||||
|
|
||||||
|
|||||||
@ -12,9 +12,10 @@ import { StackNavigationProp } from '@react-navigation/stack';
|
|||||||
import { getStyles } from './completeProfileScreen.styles';
|
import { getStyles } from './completeProfileScreen.styles';
|
||||||
import { CustomInput, PrimaryButton } from '@components';
|
import { CustomInput, PrimaryButton } from '@components';
|
||||||
import { useAppTheme } from '@theme';
|
import { useAppTheme } from '@theme';
|
||||||
import { useAppDispatch } from '../../../hooks/useAppDispatch';
|
|
||||||
import { completeProfile } from '../../../store/commonreducers/auth';
|
import { completeProfile } from '../../../store/commonreducers/auth';
|
||||||
import { AuthStackParamList } from '../../../navigation/authStack';
|
import { AuthStackParamList } from '../../../navigation/authStack';
|
||||||
|
import { useAppDispatch } from '@store';
|
||||||
|
|
||||||
type NavProp = StackNavigationProp<AuthStackParamList, 'CompleteProfileScreen'>;
|
type NavProp = StackNavigationProp<AuthStackParamList, 'CompleteProfileScreen'>;
|
||||||
|
|
||||||
@ -61,7 +62,7 @@ export const CompleteProfileScreen: React.FC = () => {
|
|||||||
|
|
||||||
<Text style={styles.labelText}>Location Label</Text>
|
<Text style={styles.labelText}>Location Label</Text>
|
||||||
<View style={styles.labelRow}>
|
<View style={styles.labelRow}>
|
||||||
{LOCATION_LABELS.map((label) => (
|
{LOCATION_LABELS.map(label => (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
key={label}
|
key={label}
|
||||||
style={[
|
style={[
|
||||||
|
|||||||
@ -3,8 +3,9 @@ import { View, Text } from 'react-native';
|
|||||||
import { getStyles } from './onboardingCompleteScreen.styles';
|
import { getStyles } from './onboardingCompleteScreen.styles';
|
||||||
import { PrimaryButton } from '@components';
|
import { PrimaryButton } from '@components';
|
||||||
import { useAppTheme } from '@theme';
|
import { useAppTheme } from '@theme';
|
||||||
import { useAppDispatch } from '../../../hooks/useAppDispatch';
|
|
||||||
import { completeOnboarding } from '../../../store/commonreducers/auth';
|
import { completeOnboarding } from '../../../store/commonreducers/auth';
|
||||||
|
import { useAppDispatch } from '@store';
|
||||||
|
|
||||||
export const OnboardingCompleteScreen: React.FC = () => {
|
export const OnboardingCompleteScreen: React.FC = () => {
|
||||||
const { colors } = useAppTheme();
|
const { colors } = useAppTheme();
|
||||||
@ -25,10 +26,7 @@ export const OnboardingCompleteScreen: React.FC = () => {
|
|||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
<View style={styles.footer}>
|
<View style={styles.footer}>
|
||||||
<PrimaryButton
|
<PrimaryButton title="Explore Now" onPress={handleExplore} />
|
||||||
title="Explore Now"
|
|
||||||
onPress={handleExplore}
|
|
||||||
/>
|
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|||||||
108
app/features/screens/otpScreen/hooks/useOtpScreen.ts
Normal file
108
app/features/screens/otpScreen/hooks/useOtpScreen.ts
Normal file
@ -0,0 +1,108 @@
|
|||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { Alert } from 'react-native';
|
||||||
|
import { useNavigation, useRoute, RouteProp } from '@react-navigation/native';
|
||||||
|
import { StackNavigationProp } from '@react-navigation/stack';
|
||||||
|
import { RootState, useAppDispatch, useAppSelector, verifyOtp } from '@store';
|
||||||
|
import { AuthStackParamList } from '@navigation/authStack';
|
||||||
|
|
||||||
|
type OtpNavProp = StackNavigationProp<AuthStackParamList, 'OtpScreen'>;
|
||||||
|
type OtpRouteProp = RouteProp<AuthStackParamList, 'OtpScreen'>;
|
||||||
|
|
||||||
|
const OTP_LENGTH = 6;
|
||||||
|
const RESEND_TIMER = 30;
|
||||||
|
|
||||||
|
export const useOtpScreen = () => {
|
||||||
|
const navigation = useNavigation<OtpNavProp>();
|
||||||
|
const route = useRoute<OtpRouteProp>();
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
const { mobileNumber } = route.params;
|
||||||
|
|
||||||
|
const { loginData, user } = useAppSelector((state: RootState) => state.auth);
|
||||||
|
|
||||||
|
const [otp, setOtp] = useState<string[]>(Array(OTP_LENGTH).fill(''));
|
||||||
|
const [activeIndex, setActiveIndex] = useState(0);
|
||||||
|
const [timer, setTimer] = useState(RESEND_TIMER);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (timer > 0) {
|
||||||
|
const interval = setInterval(() => setTimer(t => t - 1), 1000);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}
|
||||||
|
}, [timer]);
|
||||||
|
|
||||||
|
const resetOtp = useCallback(() => {
|
||||||
|
setOtp(Array(OTP_LENGTH).fill(''));
|
||||||
|
setActiveIndex(0);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleKeyPress = useCallback(
|
||||||
|
(key: string) => {
|
||||||
|
if (key === 'backspace') {
|
||||||
|
if (otp[activeIndex] || activeIndex > 0) {
|
||||||
|
const newOtp = [...otp];
|
||||||
|
if (otp[activeIndex]) {
|
||||||
|
newOtp[activeIndex] = '';
|
||||||
|
} else if (activeIndex > 0) {
|
||||||
|
newOtp[activeIndex - 1] = '';
|
||||||
|
setActiveIndex(activeIndex - 1);
|
||||||
|
}
|
||||||
|
setOtp(newOtp);
|
||||||
|
}
|
||||||
|
} else if (key >= '0' && key <= '9' && activeIndex < OTP_LENGTH) {
|
||||||
|
const newOtp = [...otp];
|
||||||
|
newOtp[activeIndex] = key;
|
||||||
|
setOtp(newOtp);
|
||||||
|
if (activeIndex < OTP_LENGTH - 1) {
|
||||||
|
setActiveIndex(activeIndex + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[otp, activeIndex],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleResend = useCallback(() => {
|
||||||
|
setTimer(RESEND_TIMER);
|
||||||
|
resetOtp();
|
||||||
|
// TODO: dispatch resend/loginWithPhone thunk here if needed
|
||||||
|
}, [resetOtp]);
|
||||||
|
|
||||||
|
const handleVerify = useCallback(async () => {
|
||||||
|
const otpString = otp.join('');
|
||||||
|
if (otpString.length !== OTP_LENGTH) return;
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
await dispatch(
|
||||||
|
verifyOtp({ phone: mobileNumber, code: otpString, role: 'CUSTOMER' }),
|
||||||
|
).unwrap();
|
||||||
|
|
||||||
|
navigation.navigate('SetLocationScreen');
|
||||||
|
} catch (error) {
|
||||||
|
const message =
|
||||||
|
typeof error === 'string' ? error : 'Invalid OTP. Please try again.';
|
||||||
|
Alert.alert('Verification Failed', message);
|
||||||
|
resetOtp();
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, [dispatch, mobileNumber, navigation, otp, resetOtp]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
// data
|
||||||
|
mobileNumber,
|
||||||
|
loginData,
|
||||||
|
user,
|
||||||
|
otp,
|
||||||
|
activeIndex,
|
||||||
|
timer,
|
||||||
|
isLoading,
|
||||||
|
otpLength: OTP_LENGTH,
|
||||||
|
isOtpComplete: otp.join('').length === OTP_LENGTH,
|
||||||
|
|
||||||
|
// actions
|
||||||
|
handleKeyPress,
|
||||||
|
handleVerify,
|
||||||
|
handleResend,
|
||||||
|
};
|
||||||
|
};
|
||||||
@ -1,4 +1,4 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React from 'react';
|
||||||
import {
|
import {
|
||||||
View,
|
View,
|
||||||
Text,
|
Text,
|
||||||
@ -6,71 +6,27 @@ import {
|
|||||||
KeyboardAvoidingView,
|
KeyboardAvoidingView,
|
||||||
Platform,
|
Platform,
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
import { useNavigation, useRoute, RouteProp } from '@react-navigation/native';
|
|
||||||
import { StackNavigationProp } from '@react-navigation/stack';
|
|
||||||
import { getStyles } from './otpScreen.styles';
|
import { getStyles } from './otpScreen.styles';
|
||||||
import { PrimaryButton } from '@components';
|
import { PrimaryButton } from '@components';
|
||||||
import { useAppTheme } from '@theme';
|
import { useAppTheme } from '@theme';
|
||||||
import { useAppDispatch } from '../../../hooks/useAppDispatch';
|
import { useOtpScreen } from './hooks/useOtpScreen';
|
||||||
import { verifyOtp } from '../../../store/commonreducers/auth';
|
|
||||||
import { AuthStackParamList } from '../../../navigation/authStack';
|
|
||||||
|
|
||||||
type OtpNavProp = StackNavigationProp<AuthStackParamList, 'OtpScreen'>;
|
|
||||||
type OtpRouteProp = RouteProp<AuthStackParamList, 'OtpScreen'>;
|
|
||||||
|
|
||||||
const OTP_LENGTH = 6;
|
|
||||||
const RESEND_TIMER = 30;
|
|
||||||
|
|
||||||
export const OtpScreen: React.FC = () => {
|
export const OtpScreen: React.FC = () => {
|
||||||
const { colors } = useAppTheme();
|
const { colors } = useAppTheme();
|
||||||
const styles = getStyles(colors);
|
const styles = getStyles(colors);
|
||||||
const navigation = useNavigation<OtpNavProp>();
|
|
||||||
const route = useRoute<OtpRouteProp>();
|
|
||||||
const dispatch = useAppDispatch();
|
|
||||||
const { mobileNumber } = route.params;
|
|
||||||
|
|
||||||
const [otp, setOtp] = useState<string[]>(Array(OTP_LENGTH).fill(''));
|
const {
|
||||||
const [activeIndex, setActiveIndex] = useState(0);
|
mobileNumber,
|
||||||
const [timer, setTimer] = useState(RESEND_TIMER);
|
otp,
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
activeIndex,
|
||||||
|
timer,
|
||||||
useEffect(() => {
|
isLoading,
|
||||||
if (timer > 0) {
|
otpLength,
|
||||||
const interval = setInterval(() => setTimer((t) => t - 1), 1000);
|
isOtpComplete,
|
||||||
return () => clearInterval(interval);
|
handleKeyPress,
|
||||||
}
|
handleVerify,
|
||||||
}, [timer]);
|
handleResend,
|
||||||
|
} = useOtpScreen();
|
||||||
const handleKeyPress = (key: string) => {
|
|
||||||
if (key === 'backspace') {
|
|
||||||
if (otp[activeIndex] || activeIndex > 0) {
|
|
||||||
const newOtp = [...otp];
|
|
||||||
if (otp[activeIndex]) {
|
|
||||||
newOtp[activeIndex] = '';
|
|
||||||
} else if (activeIndex > 0) {
|
|
||||||
newOtp[activeIndex - 1] = '';
|
|
||||||
setActiveIndex(activeIndex - 1);
|
|
||||||
}
|
|
||||||
setOtp(newOtp);
|
|
||||||
}
|
|
||||||
} else if (key >= '0' && key <= '9' && activeIndex < OTP_LENGTH) {
|
|
||||||
const newOtp = [...otp];
|
|
||||||
newOtp[activeIndex] = key;
|
|
||||||
setOtp(newOtp);
|
|
||||||
if (activeIndex < OTP_LENGTH - 1) {
|
|
||||||
setActiveIndex(activeIndex + 1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleVerify = async () => {
|
|
||||||
const otpString = otp.join('');
|
|
||||||
if (otpString.length !== OTP_LENGTH) return;
|
|
||||||
setIsLoading(true);
|
|
||||||
await dispatch(verifyOtp({ mobileNumber, otp: otpString }));
|
|
||||||
setIsLoading(false);
|
|
||||||
navigation.navigate('SetLocationScreen');
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<KeyboardAvoidingView
|
<KeyboardAvoidingView
|
||||||
@ -79,12 +35,40 @@ export const OtpScreen: React.FC = () => {
|
|||||||
>
|
>
|
||||||
<View style={styles.content}>
|
<View style={styles.content}>
|
||||||
<Text style={styles.title}>Verify OTP</Text>
|
<Text style={styles.title}>Verify OTP</Text>
|
||||||
<Text style={styles.subtitle}>
|
<Text style={styles.subtitle}>Code sent to {mobileNumber}</Text>
|
||||||
Code sent to {mobileNumber}
|
|
||||||
</Text>
|
|
||||||
|
|
||||||
<View style={styles.otpContainer}>
|
<View style={styles.otpContainer}>
|
||||||
{otp.map((digit, index) => (
|
{otp.map(
|
||||||
|
(
|
||||||
|
digit:
|
||||||
|
| string
|
||||||
|
| number
|
||||||
|
| bigint
|
||||||
|
| boolean
|
||||||
|
| React.ReactElement<
|
||||||
|
unknown,
|
||||||
|
string | React.JSXElementConstructor<any>
|
||||||
|
>
|
||||||
|
| Iterable<React.ReactNode>
|
||||||
|
| React.ReactPortal
|
||||||
|
| Promise<
|
||||||
|
| string
|
||||||
|
| number
|
||||||
|
| bigint
|
||||||
|
| boolean
|
||||||
|
| React.ReactPortal
|
||||||
|
| React.ReactElement<
|
||||||
|
unknown,
|
||||||
|
string | React.JSXElementConstructor<any>
|
||||||
|
>
|
||||||
|
| Iterable<React.ReactNode>
|
||||||
|
| null
|
||||||
|
| undefined
|
||||||
|
>
|
||||||
|
| null
|
||||||
|
| undefined,
|
||||||
|
index: React.Key | null | undefined,
|
||||||
|
) => (
|
||||||
<View
|
<View
|
||||||
key={index}
|
key={index}
|
||||||
style={[
|
style={[
|
||||||
@ -95,11 +79,12 @@ export const OtpScreen: React.FC = () => {
|
|||||||
>
|
>
|
||||||
<Text style={styles.otpCellText}>{digit}</Text>
|
<Text style={styles.otpCellText}>{digit}</Text>
|
||||||
</View>
|
</View>
|
||||||
))}
|
),
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View style={styles.keypad}>
|
<View style={styles.keypad}>
|
||||||
{[1, 2, 3, 4, 5, 6, 7, 8, 9].map((num) => (
|
{[1, 2, 3, 4, 5, 6, 7, 8, 9].map(num => (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
key={num}
|
key={num}
|
||||||
style={styles.key}
|
style={styles.key}
|
||||||
@ -129,14 +114,7 @@ export const OtpScreen: React.FC = () => {
|
|||||||
{timer > 0 ? (
|
{timer > 0 ? (
|
||||||
<Text style={styles.timerText}>Resend code in {timer}s</Text>
|
<Text style={styles.timerText}>Resend code in {timer}s</Text>
|
||||||
) : (
|
) : (
|
||||||
<TouchableOpacity
|
<TouchableOpacity onPress={handleResend} activeOpacity={0.7}>
|
||||||
onPress={() => {
|
|
||||||
setTimer(RESEND_TIMER);
|
|
||||||
setOtp(Array(OTP_LENGTH).fill(''));
|
|
||||||
setActiveIndex(0);
|
|
||||||
}}
|
|
||||||
activeOpacity={0.7}
|
|
||||||
>
|
|
||||||
<Text style={styles.resendText}>Resend OTP</Text>
|
<Text style={styles.resendText}>Resend OTP</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
)}
|
)}
|
||||||
@ -145,7 +123,7 @@ export const OtpScreen: React.FC = () => {
|
|||||||
title="Verify"
|
title="Verify"
|
||||||
onPress={handleVerify}
|
onPress={handleVerify}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
disabled={otp.join('').length !== OTP_LENGTH}
|
disabled={!isOtpComplete}
|
||||||
style={{ marginTop: 24 }}
|
style={{ marginTop: 24 }}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@ -12,11 +12,14 @@ import { StackNavigationProp } from '@react-navigation/stack';
|
|||||||
import { getStyles } from './providerDetailsScreen.styles';
|
import { getStyles } from './providerDetailsScreen.styles';
|
||||||
import { CatalogItemRow } from '@components';
|
import { CatalogItemRow } from '@components';
|
||||||
import { useAppTheme } from '@theme';
|
import { useAppTheme } from '@theme';
|
||||||
import { useAppDispatch, useAppSelector } from '../../../hooks/useAppDispatch';
|
|
||||||
import { addItem } from '../../../store/commonreducers/cart';
|
import { addItem } from '../../../store/commonreducers/cart';
|
||||||
import { getProviderCatalogApi, getProvidersApi } from '../../../api/deliveryApi';
|
import {
|
||||||
|
getProviderCatalogApi,
|
||||||
|
getProvidersApi,
|
||||||
|
} from '../../../api/deliveryApi';
|
||||||
import { CatalogItem, Provider } from '../../../interfaces';
|
import { CatalogItem, Provider } from '../../../interfaces';
|
||||||
import { AppStackParamList } from '../../../navigation/appStack';
|
import { AppStackParamList } from '../../../navigation/appStack';
|
||||||
|
import { useAppDispatch, useAppSelector } from '@store';
|
||||||
|
|
||||||
type ProviderDetailsNavProp = StackNavigationProp<
|
type ProviderDetailsNavProp = StackNavigationProp<
|
||||||
AppStackParamList,
|
AppStackParamList,
|
||||||
|
|||||||
@ -3,8 +3,8 @@ import {
|
|||||||
View,
|
View,
|
||||||
Text,
|
Text,
|
||||||
TouchableOpacity,
|
TouchableOpacity,
|
||||||
Platform,
|
|
||||||
ActivityIndicator,
|
ActivityIndicator,
|
||||||
|
InteractionManager,
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
import MapView, { Marker, Region } from 'react-native-maps';
|
import MapView, { Marker, Region } from 'react-native-maps';
|
||||||
import { useNavigation } from '@react-navigation/native';
|
import { useNavigation } from '@react-navigation/native';
|
||||||
@ -17,6 +17,7 @@ import {
|
|||||||
DEFAULT_LOCATION,
|
DEFAULT_LOCATION,
|
||||||
getCurrentLocationWithAddress,
|
getCurrentLocationWithAddress,
|
||||||
reverseGeocode,
|
reverseGeocode,
|
||||||
|
showPermissionDeniedAlert,
|
||||||
LatLng,
|
LatLng,
|
||||||
} from '../../../services/locationServices';
|
} from '../../../services/locationServices';
|
||||||
|
|
||||||
@ -29,6 +30,8 @@ export const SetLocationScreen: React.FC = () => {
|
|||||||
const styles = getStyles(colors);
|
const styles = getStyles(colors);
|
||||||
const navigation = useNavigation<NavProp>();
|
const navigation = useNavigation<NavProp>();
|
||||||
const mapRef = useRef<MapView>(null);
|
const mapRef = useRef<MapView>(null);
|
||||||
|
// Guard: never call setState after the component has unmounted
|
||||||
|
const isMounted = useRef(true);
|
||||||
|
|
||||||
const [coords, setCoords] = useState<LatLng>(DEFAULT_LOCATION);
|
const [coords, setCoords] = useState<LatLng>(DEFAULT_LOCATION);
|
||||||
const [address, setAddress] = useState('Fetching your location…');
|
const [address, setAddress] = useState('Fetching your location…');
|
||||||
@ -38,27 +41,43 @@ export const SetLocationScreen: React.FC = () => {
|
|||||||
// Fetch current location on mount
|
// Fetch current location on mount
|
||||||
// ------------------------------------------------------------------
|
// ------------------------------------------------------------------
|
||||||
const fetchCurrentLocation = useCallback(async () => {
|
const fetchCurrentLocation = useCallback(async () => {
|
||||||
|
if (!isMounted.current) return;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
console.log('--------------');
|
console.log('--------------');
|
||||||
const result = await getCurrentLocationWithAddress();
|
const result = await getCurrentLocationWithAddress();
|
||||||
console.log('result', result);
|
console.log('result', result);
|
||||||
|
if (!isMounted.current) return;
|
||||||
setCoords(result.coords);
|
setCoords(result.coords);
|
||||||
setAddress(result.address);
|
setAddress(result.address);
|
||||||
|
|
||||||
// Animate the map to the user's location
|
// Animate the map to the user's location
|
||||||
mapRef.current?.animateToRegion({ ...result.coords, ...DELTA }, 600);
|
mapRef.current?.animateToRegion({ ...result.coords, ...DELTA }, 600);
|
||||||
} catch {
|
} catch (err: unknown) {
|
||||||
// Permission denied or GPS unavailable — keep the default location
|
const message = err instanceof Error ? err.message : '';
|
||||||
console.log('--------error------');
|
if (message === 'Location permission denied') {
|
||||||
|
// Show the alert here — safe, outside the Promise chain
|
||||||
|
showPermissionDeniedAlert();
|
||||||
|
}
|
||||||
|
console.log('--------error------', err);
|
||||||
|
if (!isMounted.current) return;
|
||||||
setAddress('Could not determine location');
|
setAddress('Could not determine location');
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
if (isMounted.current) setLoading(false);
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
// Wait for the screen-transition animation to fully finish before
|
||||||
|
// calling PermissionsAndroid.request(). Calling it while the Activity
|
||||||
|
// is still transitioning causes a native crash on Android.
|
||||||
|
const task = InteractionManager.runAfterInteractions(() => {
|
||||||
fetchCurrentLocation();
|
fetchCurrentLocation();
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
task.cancel();
|
||||||
|
isMounted.current = false;
|
||||||
|
};
|
||||||
}, [fetchCurrentLocation]);
|
}, [fetchCurrentLocation]);
|
||||||
|
|
||||||
// ------------------------------------------------------------------
|
// ------------------------------------------------------------------
|
||||||
@ -83,8 +102,6 @@ export const SetLocationScreen: React.FC = () => {
|
|||||||
ref={mapRef}
|
ref={mapRef}
|
||||||
style={styles.map}
|
style={styles.map}
|
||||||
initialRegion={{ ...DEFAULT_LOCATION, ...DELTA }}
|
initialRegion={{ ...DEFAULT_LOCATION, ...DELTA }}
|
||||||
showsUserLocation
|
|
||||||
showsMyLocationButton={true}
|
|
||||||
onRegionChangeComplete={handleRegionChangeComplete}
|
onRegionChangeComplete={handleRegionChangeComplete}
|
||||||
>
|
>
|
||||||
<Marker
|
<Marker
|
||||||
|
|||||||
@ -1 +0,0 @@
|
|||||||
export * from './useLoginScreen';
|
|
||||||
60
app/interfaces/auth.ts
Normal file
60
app/interfaces/auth.ts
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
export interface LoginResponse {
|
||||||
|
message: string;
|
||||||
|
phone: string;
|
||||||
|
code: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VerifyOtpResponse {
|
||||||
|
message: string;
|
||||||
|
isNewUser: boolean;
|
||||||
|
accessToken: string;
|
||||||
|
refreshToken: string;
|
||||||
|
user: User;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface User {
|
||||||
|
id: string;
|
||||||
|
roleId: string;
|
||||||
|
roleEnum: RoleEnum;
|
||||||
|
name: string | null;
|
||||||
|
email: string | null;
|
||||||
|
phone: string;
|
||||||
|
profileImage: string | null;
|
||||||
|
status: UserStatus;
|
||||||
|
mfaEnabled: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
deletedAt: string | null;
|
||||||
|
isDeleted: boolean;
|
||||||
|
lastLogoutAt: string | null;
|
||||||
|
loginAt: string;
|
||||||
|
refreshTokenId: string;
|
||||||
|
refreshTokenHash: string;
|
||||||
|
refreshTokenExpiresAt: string;
|
||||||
|
categoryPreferences: CategoryPreference[];
|
||||||
|
gender: Gender | null;
|
||||||
|
dateOfBirth: string | null;
|
||||||
|
role: Role;
|
||||||
|
merchants: Merchant[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Role {
|
||||||
|
id: string;
|
||||||
|
code: string;
|
||||||
|
scope: string;
|
||||||
|
isSystem: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CategoryPreference {
|
||||||
|
// Extend when backend starts returning data
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Merchant {
|
||||||
|
// Extend when backend starts returning data
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RoleEnum = 'CUSTOMER';
|
||||||
|
|
||||||
|
export type UserStatus = 'ONBOARDING' | 'ACTIVE' | 'INACTIVE' | 'BLOCKED';
|
||||||
|
|
||||||
|
export type Gender = 'MALE' | 'FEMALE' | 'OTHER';
|
||||||
@ -43,7 +43,12 @@ export interface CartItem {
|
|||||||
quantity: number;
|
quantity: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type OrderStatus = 'placed' | 'confirmed' | 'dispatched' | 'delivered' | 'cancelled';
|
export type OrderStatus =
|
||||||
|
| 'placed'
|
||||||
|
| 'confirmed'
|
||||||
|
| 'dispatched'
|
||||||
|
| 'delivered'
|
||||||
|
| 'cancelled';
|
||||||
|
|
||||||
export interface Order {
|
export interface Order {
|
||||||
id: string;
|
id: string;
|
||||||
@ -83,3 +88,5 @@ export interface PaymentMethod {
|
|||||||
label: string;
|
label: string;
|
||||||
icon?: string;
|
icon?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export * from './auth';
|
||||||
|
|||||||
@ -15,7 +15,7 @@ const STORAGE_KEYS = {
|
|||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
// ─── Config ──────────────────────────────────────────────────────────────────
|
// ─── Config ──────────────────────────────────────────────────────────────────
|
||||||
const BASE_URL = 'https://api.example.com/v1'; // TODO: replace with your actual base URL
|
const BASE_URL = 'https://a84e-202-8-116-13.ngrok-free.app'; // TODO: replace with your actual base URL
|
||||||
|
|
||||||
// ─── Token Helpers ───────────────────────────────────────────────────────────
|
// ─── Token Helpers ───────────────────────────────────────────────────────────
|
||||||
export const tokenManager = {
|
export const tokenManager = {
|
||||||
@ -53,8 +53,8 @@ axiosInstance.interceptors.request.use(
|
|||||||
async (config: InternalAxiosRequestConfig) => {
|
async (config: InternalAxiosRequestConfig) => {
|
||||||
// Skip attaching token for auth endpoints (login, register, refresh)
|
// Skip attaching token for auth endpoints (login, register, refresh)
|
||||||
const publicPaths = [
|
const publicPaths = [
|
||||||
'/auth/login',
|
'/auth/otp/request',
|
||||||
'/auth/register',
|
'/auth/otp/verify',
|
||||||
'/auth/refresh-token',
|
'/auth/refresh-token',
|
||||||
];
|
];
|
||||||
const isPublic = publicPaths.some(path => config.url?.includes(path));
|
const isPublic = publicPaths.some(path => config.url?.includes(path));
|
||||||
@ -68,7 +68,7 @@ axiosInstance.interceptors.request.use(
|
|||||||
|
|
||||||
// Debug logging (remove in production)
|
// Debug logging (remove in production)
|
||||||
if (__DEV__) {
|
if (__DEV__) {
|
||||||
console.log(`🚀 [API] ${config.method?.toUpperCase()} ${config.url}`);
|
// console.log(`🚀 [API] ${config.method?.toUpperCase()} ${config.url}`);s
|
||||||
}
|
}
|
||||||
|
|
||||||
return config;
|
return config;
|
||||||
|
|||||||
@ -46,13 +46,21 @@ export const requestLocationPermission = async (): Promise<boolean> => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Android
|
// Android — check first to avoid showing the dialog when already granted
|
||||||
try {
|
try {
|
||||||
|
const alreadyGranted = await PermissionsAndroid.check(
|
||||||
|
PermissionsAndroid.PERMISSIONS.ACCESS_FINE_LOCATION,
|
||||||
|
);
|
||||||
|
if (alreadyGranted) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
const granted = await PermissionsAndroid.request(
|
const granted = await PermissionsAndroid.request(
|
||||||
PermissionsAndroid.PERMISSIONS.ACCESS_FINE_LOCATION,
|
PermissionsAndroid.PERMISSIONS.ACCESS_FINE_LOCATION,
|
||||||
{
|
{
|
||||||
title: 'Location Permission',
|
title: 'Location Permission',
|
||||||
message: 'This app needs access to your location to set it on the map.',
|
message:
|
||||||
|
'This app needs access to your location to set it on the map.',
|
||||||
buttonNeutral: 'Ask Me Later',
|
buttonNeutral: 'Ask Me Later',
|
||||||
buttonNegative: 'Cancel',
|
buttonNegative: 'Cancel',
|
||||||
buttonPositive: 'OK',
|
buttonPositive: 'OK',
|
||||||
@ -88,13 +96,12 @@ export const showPermissionDeniedAlert = (): void => {
|
|||||||
* Get the device's current GPS position.
|
* Get the device's current GPS position.
|
||||||
* Wraps the callback-based Geolocation API in a Promise.
|
* Wraps the callback-based Geolocation API in a Promise.
|
||||||
*/
|
*/
|
||||||
export const getCurrentPosition = async (): Promise<LatLng> => {
|
/**
|
||||||
try {
|
* Wraps Geolocation.getCurrentPosition in a Promise.
|
||||||
console.log('get current position');
|
* Tries high-accuracy first; falls back to low-accuracy on failure.
|
||||||
console.log('Geolocation:', Geolocation);
|
*/
|
||||||
console.log('getCurrentPosition:', Geolocation?.getCurrentPosition);
|
const getPositionOnce = (highAccuracy: boolean): Promise<LatLng> =>
|
||||||
|
new Promise<LatLng>((resolve, reject) => {
|
||||||
const location = await new Promise<LatLng>((resolve, reject) => {
|
|
||||||
Geolocation.getCurrentPosition(
|
Geolocation.getCurrentPosition(
|
||||||
(position: GeoPosition) => {
|
(position: GeoPosition) => {
|
||||||
resolve({
|
resolve({
|
||||||
@ -103,23 +110,33 @@ export const getCurrentPosition = async (): Promise<LatLng> => {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
(error: GeoError) => {
|
(error: GeoError) => {
|
||||||
console.log('Geolocation Error:', error);
|
console.log('Geolocation error (highAccuracy=' + highAccuracy + '):', error);
|
||||||
reject(error);
|
reject(error);
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
enableHighAccuracy: true,
|
enableHighAccuracy: highAccuracy,
|
||||||
timeout: 15000,
|
timeout: highAccuracy ? 5000 : 10000,
|
||||||
maximumAge: 10000,
|
maximumAge: 10000,
|
||||||
forceRequestLocation: true,
|
forceRequestLocation: false, // avoid native dialog that can crash
|
||||||
showLocationDialog: true,
|
showLocationDialog: true,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
return location;
|
export const getCurrentPosition = async (): Promise<LatLng> => {
|
||||||
} catch (error) {
|
console.log('getCurrentPosition called');
|
||||||
console.error('Failed to get current position:', error);
|
try {
|
||||||
throw error;
|
// First attempt: high accuracy (GPS)
|
||||||
|
return await getPositionOnce(true);
|
||||||
|
} catch (highAccuracyError) {
|
||||||
|
console.warn('High-accuracy location failed, retrying with low accuracy…', highAccuracyError);
|
||||||
|
try {
|
||||||
|
// Fallback: network / cell-tower accuracy
|
||||||
|
return await getPositionOnce(false);
|
||||||
|
} catch (lowAccuracyError) {
|
||||||
|
console.error('Failed to get current position:', lowAccuracyError);
|
||||||
|
throw lowAccuracyError;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -146,9 +163,38 @@ export const reverseGeocode = async (coords: LatLng): Promise<string> => {
|
|||||||
return data.results[0].formatted_address;
|
return data.results[0].formatted_address;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
console.warn('Google Maps Geocoding failed:', data.status, data.error_message || '');
|
||||||
|
|
||||||
|
// Fallback to OpenStreetMap Nominatim
|
||||||
|
const osmUrl = `https://nominatim.openstreetmap.org/reverse?format=jsonv2&lat=${coords.latitude}&lon=${coords.longitude}`;
|
||||||
|
const osmResponse = await fetch(osmUrl, {
|
||||||
|
headers: {
|
||||||
|
'User-Agent': 'SGDeliveryCustomerApp/1.0',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const osmData = await osmResponse.json();
|
||||||
|
if (osmData && osmData.display_name) {
|
||||||
|
return osmData.display_name;
|
||||||
|
}
|
||||||
|
|
||||||
return 'Address not found';
|
return 'Address not found';
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log('reverseGeocode error', error);
|
console.log('reverseGeocode error', error);
|
||||||
|
try {
|
||||||
|
// Fallback to OpenStreetMap Nominatim on catch
|
||||||
|
const osmUrl = `https://nominatim.openstreetmap.org/reverse?format=jsonv2&lat=${coords.latitude}&lon=${coords.longitude}`;
|
||||||
|
const osmResponse = await fetch(osmUrl, {
|
||||||
|
headers: {
|
||||||
|
'User-Agent': 'SGDeliveryCustomerApp/1.0',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const osmData = await osmResponse.json();
|
||||||
|
if (osmData && osmData.display_name) {
|
||||||
|
return osmData.display_name;
|
||||||
|
}
|
||||||
|
} catch (osmError) {
|
||||||
|
console.log('OSM fallback error', osmError);
|
||||||
|
}
|
||||||
return 'Unable to fetch address';
|
return 'Unable to fetch address';
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@ -166,9 +212,10 @@ export const reverseGeocode = async (coords: LatLng): Promise<string> => {
|
|||||||
export const getCurrentLocationWithAddress =
|
export const getCurrentLocationWithAddress =
|
||||||
async (): Promise<LocationResult> => {
|
async (): Promise<LocationResult> => {
|
||||||
const hasPermission = await requestLocationPermission();
|
const hasPermission = await requestLocationPermission();
|
||||||
|
console.log('hasPermission', hasPermission);
|
||||||
if (!hasPermission) {
|
if (!hasPermission) {
|
||||||
showPermissionDeniedAlert();
|
// Alert shown by the caller so it doesn't fire inside a Promise chain
|
||||||
|
// on mount (which can cause a crash on some Android versions).
|
||||||
throw new Error('Location permission denied');
|
throw new Error('Location permission denied');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,129 +1,66 @@
|
|||||||
import { createAction, createReducer } from '@reduxjs/toolkit';
|
import { createReducer } from '@reduxjs/toolkit';
|
||||||
import { User, Location } from '../../../interfaces';
|
import { LoginResponse, VerifyOtpResponse } from '../../../interfaces';
|
||||||
import { loginWithPhone, verifyOtp, updateProfile, logoutUser } from './thunk';
|
import { loginWithPhone, verifyOtp } from './thunk';
|
||||||
|
import { User } from '@interfaces/auth';
|
||||||
|
|
||||||
// ─── State ───────────────────────────────────────────────────────────────────
|
// ─── State ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export interface AuthState {
|
export interface AuthState {
|
||||||
|
loginData: LoginResponse | null;
|
||||||
user: User | null;
|
user: User | null;
|
||||||
isAuthenticated: boolean;
|
accessToken: string | null;
|
||||||
isLoading: boolean;
|
refreshToken: string | null;
|
||||||
onboardingComplete: boolean;
|
isNewUser: boolean | null;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
|
isLoading: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const initialState: AuthState = {
|
const initialState: AuthState = {
|
||||||
|
loginData: null,
|
||||||
user: null,
|
user: null,
|
||||||
isAuthenticated: false,
|
accessToken: null,
|
||||||
|
refreshToken: null,
|
||||||
|
isNewUser: null,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
onboardingComplete: false,
|
|
||||||
error: null,
|
error: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
// ─── Sync Actions ────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export const setUser = createAction<User>('auth/setUser');
|
|
||||||
export const setLocation = createAction<Location>('auth/setLocation');
|
|
||||||
export const completeProfile = createAction<{
|
|
||||||
name: string;
|
|
||||||
email: string;
|
|
||||||
locationLabels: string[];
|
|
||||||
}>('auth/completeProfile');
|
|
||||||
export const completeOnboarding = createAction('auth/completeOnboarding');
|
|
||||||
export const logout = createAction('auth/logout');
|
|
||||||
export const clearError = createAction('auth/clearError');
|
|
||||||
|
|
||||||
// ─── Reducer ─────────────────────────────────────────────────────────────────
|
// ─── Reducer ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const authReducer = createReducer(initialState, builder => {
|
const authReducer = createReducer(initialState, builder => {
|
||||||
builder
|
builder
|
||||||
// ── Sync Actions ───────────────────────────────────────────────────────
|
// Login
|
||||||
.addCase(setUser, (state, action) => {
|
|
||||||
state.user = action.payload;
|
|
||||||
})
|
|
||||||
.addCase(setLocation, (state, _action) => {
|
|
||||||
if (state.user) {
|
|
||||||
state.user = { ...state.user };
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.addCase(completeProfile, (state, action) => {
|
|
||||||
if (state.user) {
|
|
||||||
state.user = {
|
|
||||||
...state.user,
|
|
||||||
name: action.payload.name,
|
|
||||||
email: action.payload.email,
|
|
||||||
locationLabels: action.payload.locationLabels,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.addCase(completeOnboarding, state => {
|
|
||||||
state.onboardingComplete = true;
|
|
||||||
})
|
|
||||||
.addCase(logout, state => {
|
|
||||||
state.user = null;
|
|
||||||
state.isAuthenticated = false;
|
|
||||||
state.onboardingComplete = false;
|
|
||||||
state.error = null;
|
|
||||||
})
|
|
||||||
.addCase(clearError, state => {
|
|
||||||
state.error = null;
|
|
||||||
})
|
|
||||||
|
|
||||||
// ── loginWithPhone ─────────────────────────────────────────────────────
|
|
||||||
.addCase(loginWithPhone.pending, state => {
|
.addCase(loginWithPhone.pending, state => {
|
||||||
state.isLoading = true;
|
state.isLoading = true;
|
||||||
state.error = null;
|
state.error = null;
|
||||||
})
|
})
|
||||||
.addCase(loginWithPhone.fulfilled, state => {
|
.addCase(loginWithPhone.fulfilled, (state, action) => {
|
||||||
|
state.loginData = action.payload;
|
||||||
state.isLoading = false;
|
state.isLoading = false;
|
||||||
|
state.error = null;
|
||||||
})
|
})
|
||||||
.addCase(loginWithPhone.rejected, (state, action) => {
|
.addCase(loginWithPhone.rejected, (state, action) => {
|
||||||
state.isLoading = false;
|
state.isLoading = false;
|
||||||
state.error = (action.payload as string) || 'Login failed';
|
state.error = action.payload as string;
|
||||||
})
|
})
|
||||||
|
|
||||||
// ── verifyOtp ──────────────────────────────────────────────────────────
|
// Verify OTP
|
||||||
.addCase(verifyOtp.pending, state => {
|
.addCase(verifyOtp.pending, state => {
|
||||||
state.isLoading = true;
|
state.isLoading = true;
|
||||||
state.error = null;
|
state.error = null;
|
||||||
})
|
})
|
||||||
.addCase(verifyOtp.fulfilled, (state, action) => {
|
.addCase(verifyOtp.fulfilled, (state, action) => {
|
||||||
|
const { user, accessToken, refreshToken, isNewUser } = action.payload;
|
||||||
|
state.user = user;
|
||||||
|
state.accessToken = accessToken;
|
||||||
|
state.refreshToken = refreshToken;
|
||||||
|
state.isNewUser = isNewUser;
|
||||||
state.isLoading = false;
|
state.isLoading = false;
|
||||||
state.isAuthenticated = true;
|
state.error = null;
|
||||||
state.user = action.payload.user;
|
|
||||||
})
|
})
|
||||||
.addCase(verifyOtp.rejected, (state, action) => {
|
.addCase(verifyOtp.rejected, (state, action) => {
|
||||||
state.isLoading = false;
|
state.isLoading = false;
|
||||||
state.error = (action.payload as string) || 'OTP verification failed';
|
state.error = action.payload as string;
|
||||||
})
|
|
||||||
|
|
||||||
// ── updateProfile ──────────────────────────────────────────────────────
|
|
||||||
.addCase(updateProfile.pending, state => {
|
|
||||||
state.isLoading = true;
|
|
||||||
state.error = null;
|
|
||||||
})
|
|
||||||
.addCase(updateProfile.fulfilled, (state, action) => {
|
|
||||||
state.isLoading = false;
|
|
||||||
state.user = action.payload.user;
|
|
||||||
})
|
|
||||||
.addCase(updateProfile.rejected, (state, action) => {
|
|
||||||
state.isLoading = false;
|
|
||||||
state.error = (action.payload as string) || 'Profile update failed';
|
|
||||||
})
|
|
||||||
|
|
||||||
// ── logoutUser ─────────────────────────────────────────────────────────
|
|
||||||
.addCase(logoutUser.fulfilled, state => {
|
|
||||||
state.user = null;
|
|
||||||
state.isAuthenticated = false;
|
|
||||||
state.onboardingComplete = false;
|
|
||||||
state.error = null;
|
|
||||||
})
|
|
||||||
.addCase(logoutUser.rejected, state => {
|
|
||||||
// Still clear state even if API fails (tokens already cleared in thunk)
|
|
||||||
state.user = null;
|
|
||||||
state.isAuthenticated = false;
|
|
||||||
state.onboardingComplete = false;
|
|
||||||
state.error = null;
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -1,18 +1,23 @@
|
|||||||
import { createAsyncThunk } from '@reduxjs/toolkit';
|
import { createAsyncThunk } from '@reduxjs/toolkit';
|
||||||
import { loginApi, verifyOtpApi, updateProfileApi, logoutApi } from '../../../api/authApi';
|
import {
|
||||||
|
loginApi,
|
||||||
|
verifyOtpApi,
|
||||||
|
updateProfileApi,
|
||||||
|
logoutApi,
|
||||||
|
} from '../../../api/authApi';
|
||||||
import { tokenManager } from '../../../services/apiClient';
|
import { tokenManager } from '../../../services/apiClient';
|
||||||
|
|
||||||
// ─── Login ───────────────────────────────────────────────────────────────────
|
// ─── Login ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export const loginWithPhone = createAsyncThunk(
|
export const loginWithPhone = createAsyncThunk(
|
||||||
'auth/loginWithPhone',
|
'auth/loginWithPhone',
|
||||||
async (mobileNumber: string, { rejectWithValue }) => {
|
async (phone: string, { rejectWithValue }) => {
|
||||||
try {
|
try {
|
||||||
const response = await loginApi(mobileNumber);
|
const response = await loginApi(phone);
|
||||||
|
console.log(response);
|
||||||
return response;
|
return response;
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
const message =
|
const message = error instanceof Error ? error.message : 'Login failed';
|
||||||
error instanceof Error ? error.message : 'Login failed';
|
|
||||||
return rejectWithValue(message);
|
return rejectWithValue(message);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@ -23,11 +28,11 @@ export const loginWithPhone = createAsyncThunk(
|
|||||||
export const verifyOtp = createAsyncThunk(
|
export const verifyOtp = createAsyncThunk(
|
||||||
'auth/verifyOtp',
|
'auth/verifyOtp',
|
||||||
async (
|
async (
|
||||||
{ mobileNumber, otp }: { mobileNumber: string; otp: string },
|
{ phone, code, role }: { phone: string; code: string; role: string },
|
||||||
{ rejectWithValue },
|
{ rejectWithValue },
|
||||||
) => {
|
) => {
|
||||||
try {
|
try {
|
||||||
const response = await verifyOtpApi(mobileNumber, otp);
|
const response = await verifyOtpApi(phone, code, role);
|
||||||
|
|
||||||
// Persist tokens on successful verification
|
// Persist tokens on successful verification
|
||||||
if (response.accessToken && response.refreshToken) {
|
if (response.accessToken && response.refreshToken) {
|
||||||
@ -77,8 +82,7 @@ export const logoutUser = createAsyncThunk(
|
|||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
// Clear tokens even if API call fails
|
// Clear tokens even if API call fails
|
||||||
await tokenManager.clearTokens();
|
await tokenManager.clearTokens();
|
||||||
const message =
|
const message = error instanceof Error ? error.message : 'Logout failed';
|
||||||
error instanceof Error ? error.message : 'Logout failed';
|
|
||||||
return rejectWithValue(message);
|
return rejectWithValue(message);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@ -9,6 +9,10 @@ module.exports = {
|
|||||||
'@components': './app/components',
|
'@components': './app/components',
|
||||||
'@features/screens': './app/features/screens',
|
'@features/screens': './app/features/screens',
|
||||||
'@theme': './app/theme',
|
'@theme': './app/theme',
|
||||||
|
'@hooks': './app/hooks',
|
||||||
|
'@navigation': './app/navigation',
|
||||||
|
'@services': './app/services',
|
||||||
|
'@store': './app/store',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
4
index.js
4
index.js
@ -2,4 +2,8 @@ import { AppRegistry } from 'react-native';
|
|||||||
import App from './app/App';
|
import App from './app/App';
|
||||||
import { name as appName } from './app.json';
|
import { name as appName } from './app.json';
|
||||||
|
|
||||||
|
if (__DEV__) {
|
||||||
|
require('./ReactotronConfig');
|
||||||
|
}
|
||||||
|
|
||||||
AppRegistry.registerComponent(appName, () => App);
|
AppRegistry.registerComponent(appName, () => App);
|
||||||
|
|||||||
@ -29,6 +29,7 @@
|
|||||||
"react-native-svg": "^15.15.5",
|
"react-native-svg": "^15.15.5",
|
||||||
"react-native-worklets": "^0.10.0",
|
"react-native-worklets": "^0.10.0",
|
||||||
"react-redux": "^9.3.0",
|
"react-redux": "^9.3.0",
|
||||||
|
"reactotron-react-native": "^5.2.0",
|
||||||
"redux-persist": "^6.0.0",
|
"redux-persist": "^6.0.0",
|
||||||
"redux-thunk": "^3.1.0"
|
"redux-thunk": "^3.1.0"
|
||||||
},
|
},
|
||||||
|
|||||||
@ -9,7 +9,17 @@
|
|||||||
"@features/screens": ["./app/features/screens"],
|
"@features/screens": ["./app/features/screens"],
|
||||||
"@features/screens/*": ["./app/features/screens/*"],
|
"@features/screens/*": ["./app/features/screens/*"],
|
||||||
"@theme": ["./app/theme"],
|
"@theme": ["./app/theme"],
|
||||||
"@theme/*": ["./app/theme/*"]
|
"@theme/*": ["./app/theme/*"],
|
||||||
|
"@api": ["./app/api"],
|
||||||
|
"@api/*": ["./app/api/*"],
|
||||||
|
"@store": ["./app/store"],
|
||||||
|
"@store/*": ["./app/store/*"],
|
||||||
|
"@interfaces": ["./app/interfaces"],
|
||||||
|
"@interfaces/*": ["./app/interfaces/*"],
|
||||||
|
"@navigation": ["./app/navigation"],
|
||||||
|
"@navigation/*": ["./app/navigation/*"],
|
||||||
|
"@hooks": ["./app/hooks"],
|
||||||
|
"@hooks/*": ["./app/hooks/*"]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"include": ["**/*.ts", "**/*.tsx"],
|
"include": ["**/*.ts", "**/*.tsx"],
|
||||||
|
|||||||
26
yarn.lock
26
yarn.lock
@ -5290,6 +5290,11 @@ minipass@^4.2.4:
|
|||||||
resolved "https://registry.yarnpkg.com/minipass/-/minipass-7.1.3.tgz#79389b4eb1bb2d003a9bba87d492f2bd37bdc65b"
|
resolved "https://registry.yarnpkg.com/minipass/-/minipass-7.1.3.tgz#79389b4eb1bb2d003a9bba87d492f2bd37bdc65b"
|
||||||
integrity sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==
|
integrity sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==
|
||||||
|
|
||||||
|
mitt@^3.0.1:
|
||||||
|
version "3.0.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/mitt/-/mitt-3.0.1.tgz#ea36cf0cc30403601ae074c8f77b7092cdab36d1"
|
||||||
|
integrity sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==
|
||||||
|
|
||||||
mkdirp@^1.0.4:
|
mkdirp@^1.0.4:
|
||||||
version "1.0.4"
|
version "1.0.4"
|
||||||
resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-1.0.4.tgz#3eb5ed62622756d79a5f0e2a221dfebad75c2f7e"
|
resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-1.0.4.tgz#3eb5ed62622756d79a5f0e2a221dfebad75c2f7e"
|
||||||
@ -5935,6 +5940,27 @@ react@19.2.3:
|
|||||||
resolved "https://registry.yarnpkg.com/react/-/react-19.2.3.tgz#d83e5e8e7a258cf6b4fe28640515f99b87cd19b8"
|
resolved "https://registry.yarnpkg.com/react/-/react-19.2.3.tgz#d83e5e8e7a258cf6b4fe28640515f99b87cd19b8"
|
||||||
integrity sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==
|
integrity sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==
|
||||||
|
|
||||||
|
reactotron-core-client@2.10.0:
|
||||||
|
version "2.10.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/reactotron-core-client/-/reactotron-core-client-2.10.0.tgz#494e6e56bb018774a0a63c797d48b53ac6986256"
|
||||||
|
integrity sha512-mOzkc1Rb4sN98OLY/frkP0e1A60bTFjuXUgG8D4m+pwCcjVCWZupdSFV8c5+gqcZBnreoVR20/ko6D8X/7KPvg==
|
||||||
|
dependencies:
|
||||||
|
reactotron-core-contract "0.4.0"
|
||||||
|
|
||||||
|
reactotron-core-contract@0.4.0:
|
||||||
|
version "0.4.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/reactotron-core-contract/-/reactotron-core-contract-0.4.0.tgz#7227fe19be6b564c083318798bdf88af943ca995"
|
||||||
|
integrity sha512-o7m4mSzaUM/IGDc1dBfe0q6rgSi7mZCP7FCs3kHF7AJQwXBOKx/awjVaeNVqz+w6ADiw7X4dtDdQvEROAXdD3w==
|
||||||
|
|
||||||
|
reactotron-react-native@^5.2.0:
|
||||||
|
version "5.2.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/reactotron-react-native/-/reactotron-react-native-5.2.0.tgz#e722f92860951b383d17400115825aa3a3b10502"
|
||||||
|
integrity sha512-7Jd47Eti4wvyn1p5MsQ4mEg2mGG8B5HIZHASjK5RKVfOajEP9Sf4Vume9URG6inB/UQWGpBP6ahCGqcSo4qn3w==
|
||||||
|
dependencies:
|
||||||
|
mitt "^3.0.1"
|
||||||
|
reactotron-core-client "2.10.0"
|
||||||
|
reactotron-core-contract "0.4.0"
|
||||||
|
|
||||||
readable-stream@^3.4.0:
|
readable-stream@^3.4.0:
|
||||||
version "3.6.2"
|
version "3.6.2"
|
||||||
resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-3.6.2.tgz#56a9b36ea965c00c5a93ef31eb111a0f11056967"
|
resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-3.6.2.tgz#56a9b36ea965c00c5a93ef31eb111a0f11056967"
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user